Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -51,3 +51,40 @@
|
||||
.app-settings__error:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-settings__rules {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.app-settings__rules > li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 15em) 1fr;
|
||||
align-items: center;
|
||||
gap: var(--spacing-12);
|
||||
padding: var(--spacing-4) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-settings__keys {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.app-settings__keys:only-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.app-settings__keys > kbd {
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
+26
-11
@@ -37,7 +37,7 @@ const MOUSE_RULES: LocaleKey[] = [
|
||||
"App_Settings_Rule_Pinch",
|
||||
];
|
||||
|
||||
/** ③ 안내표 줄 — [자리, 키] */
|
||||
/** ③ 안내 — [자리, 키] */
|
||||
const KEY_ROWS: [LocaleKey, LocaleKey][] = [
|
||||
["App_Settings_Keys_All", "App_Settings_Keys_All_Keys"],
|
||||
["App_Settings_Keys_Profile", "App_Settings_Keys_Profile_Keys"],
|
||||
@@ -55,6 +55,26 @@ const normalize = (p: InputPrefs): InputPrefs => ({
|
||||
});
|
||||
const snapshot = (p: InputPrefs): string => JSON.stringify(normalize(p));
|
||||
|
||||
/** 규칙 목록 — 한 줄 = 「입력 = 동작」 · 입력은 키 칩(` · ` 로 여럿) · 동작 없는 줄은 칩만 */
|
||||
function ruleList(keys: LocaleKey[]): HTMLElement {
|
||||
const items = keys
|
||||
.flatMap((key) => L(key).split("\n"))
|
||||
.map((line) => {
|
||||
const at = line.indexOf(" = ");
|
||||
const input = at < 0 ? line : line.slice(0, at);
|
||||
return el("li", {
|
||||
children: [
|
||||
el("span", {
|
||||
className: "app-settings__keys",
|
||||
children: input.split(" · ").map((text) => el("kbd", { text })),
|
||||
}),
|
||||
...(at < 0 ? [] : [el("span", { text: line.slice(at + 3) })]),
|
||||
],
|
||||
});
|
||||
});
|
||||
return el("ul", { className: "app-settings__rules", children: items });
|
||||
}
|
||||
|
||||
function mouseTab(draft: InputPrefs): HTMLElement {
|
||||
const group = el("div", {
|
||||
className: "app-settings__radios",
|
||||
@@ -73,18 +93,14 @@ function mouseTab(draft: InputPrefs): HTMLElement {
|
||||
el("h4", { text: L("App_Settings_Wheel") }),
|
||||
group,
|
||||
el("h4", { text: L("App_Settings_Rules") }),
|
||||
el("ul", { children: MOUSE_RULES.map((key) => el("li", { text: L(key) })) }),
|
||||
ruleList(MOUSE_RULES),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function keysTab(): HTMLElement {
|
||||
const rows = KEY_ROWS.map(([place, keys]) =>
|
||||
el("tr", { children: [el("th", { text: L(place) }), el("td", { text: L(keys) })] }),
|
||||
);
|
||||
return el("table", {
|
||||
className: "app-settings__table",
|
||||
children: [el("tbody", { children: rows })],
|
||||
return el("div", {
|
||||
children: KEY_ROWS.flatMap(([place, keys]) => [el("h4", { text: L(place) }), ruleList([keys])]),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,10 +154,9 @@ export function openAppSettings(): void {
|
||||
const saveButton = createButton({
|
||||
label: L("Common_Btn_Save"),
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
onClick: () => void save(),
|
||||
onClick: () => save() && modal.close(),
|
||||
});
|
||||
openModal({
|
||||
const modal = openModal({
|
||||
title: L("App_Settings"),
|
||||
closeLabel: L("Common_Btn_Close"),
|
||||
dialogClass: "app-settings",
|
||||
|
||||
+11
-1
@@ -525,6 +525,16 @@ html.is-bm-route .app-burger {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
html.is-bm-route[data-touch="on"] .app-burger { min-height: 44px; }
|
||||
/* 좌측 패널이 펼쳐진 동안 ☰ 는 패널 제목 줄 앞 칸에 든 모양(테두리 · 바탕 · 그림자 없음 · 자리 그대로) —
|
||||
패널을 접으면 떠 있는 단추(위 규칙)로 돌아감(352-4) */
|
||||
html.is-bm-route:has(.ui-workflow-overlay__panel--title:not(.is-collapsed)) .app-burger {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
html.is-bm-route:has(.ui-workflow-overlay__panel--title:not(.is-collapsed)) .app-burger:hover {
|
||||
background: color-mix(in srgb, var(--color-text) 10%, transparent);
|
||||
}
|
||||
html.is-bm-route .app-header.is-drawer-open .app-drawer {
|
||||
left: 0;
|
||||
right: auto;
|
||||
@@ -535,7 +545,7 @@ html.is-bm-route .app-drawer__brand {
|
||||
min-height: 44px;
|
||||
padding-left: calc(var(--app-burger-inset) - var(--spacing-16));
|
||||
}
|
||||
html.is-bm-route .app-drawer__brand .app-brand { align-self: flex-start; }
|
||||
html.is-bm-route .app-drawer__brand .app-brand { align-self: flex-end; }
|
||||
`;
|
||||
|
||||
export function injectShellStyles(): void {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_leave.ts
|
||||
* B05 · B06 떠남 확인(346-4) — 저장 안 한 조작(초안 통)이 있으면 탭 닫기 · 다른 페이지로 갈 때
|
||||
* 공용 물음(`ui_template_leave_dialog` · [취소] [버리고 넘어감] [저장 후 넘어감]).
|
||||
* 공용 물음(`ui_template_leave_dialog` · [취소] [폐기] [저장]).
|
||||
*
|
||||
* 고침 = 초안 통 칸 중 고침이 아닌 칸(고름 · 관 id 장부 · 종류 캐시 · 고른 구조물 넘김) ·
|
||||
* 다른 페이지 칸(b08 · b09) · 저장 뒤에도 남기는 표준 횡단면(std-cross)을 뺀 것.
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from "../b_page_state";
|
||||
import { currentRoute, setLeaveGuard } from "../router";
|
||||
|
||||
/** 초안 통이지만 고침으로 안 보는 칸 — 묻지 않고 [버리고 넘어감]에도 안 지움. */
|
||||
/** 초안 통이지만 고침으로 안 보는 칸 — 묻지 않고 [폐기]에도 안 지움. */
|
||||
const NOT_EDITS = new Set<StateName>([
|
||||
"selection",
|
||||
"pipe-ids",
|
||||
@@ -81,7 +81,7 @@ export function designDirty(projectId: string): boolean {
|
||||
return cleanMarks.get(projectId) !== digest(signature);
|
||||
}
|
||||
|
||||
/** [버리고 넘어감] — 고침 칸만 지움(모든 노선). */
|
||||
/** [폐기] — 고침 칸만 지움(모든 노선). */
|
||||
export function discardDesignEdits(projectId: string): void {
|
||||
editDraftNames().forEach((name) => clearState(name, projectId));
|
||||
}
|
||||
|
||||
@@ -55,20 +55,13 @@
|
||||
font-size: var(--btn-bar-font-size);
|
||||
}
|
||||
|
||||
/* 유토곡선 · 배수유역 판 [창 분할](계획서 325-3) — 종단 [창 분할]과 같은 양식 · 유토는 판 좌상단에 띄움. */
|
||||
/* 유토곡선 · 배수유역 판 [창 분할](계획서 325-3) — 종단 [창 분할]과 같은 양식 · 두 판 머리줄 맨 앞. */
|
||||
.b05-subpanel__popout {
|
||||
flex: none;
|
||||
padding: var(--btn-bar-padding);
|
||||
font-size: var(--btn-bar-font-size);
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-overlay > .b05-subpanel__popout {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: var(--spacing-8);
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
/* 새 창으로 뗀 유토곡선 · 배수유역 판 — 창 전체를 채움 · 높이 · 폭 끌기와 접기 손잡이는 감춤. */
|
||||
.b05-profile__masshaul-overlay.is-popout,
|
||||
.b05-drainage.is-popout {
|
||||
@@ -79,6 +72,7 @@
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-overlay.is-popout > .ui-resizer,
|
||||
.b05-profile__masshaul-overlay.is-popout .b05-profile__masshaul-handle,
|
||||
.b05-drainage.is-popout > .ui-resizer,
|
||||
.b05-drainage.is-popout > .ui-workflow-overlay__handle {
|
||||
display: none;
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { DesignView } from "./design_dock";
|
||||
import { saveSharedDesign, type DesignPageShared } from "./design_shared";
|
||||
import { guardDesignLeave } from "./design_leave";
|
||||
import { usePipeIds } from "./design_pipe_ids";
|
||||
import { setRouteShiftOpener } from "../../B06_Section/B06_Section_UI_Cross_Center";
|
||||
import "./design_page.css";
|
||||
|
||||
export type { DesignPageShared } from "./design_shared";
|
||||
@@ -140,6 +141,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
|
||||
ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready,
|
||||
};
|
||||
guardDesignLeave(projectId, page, () => saveSharedDesign(shared, false)); // 떠남 확인(346-4)
|
||||
setRouteShiftOpener(() => shared.ensureView("profile"), showToast); // 중심점 좌우 = 편집기 숨긴 채(356-3)
|
||||
|
||||
/** 한 벌이 보기를 따라간다 — 좌측 칸 · 단추 글자 · 종단도 판 · 진행 단계 · 제목. */
|
||||
const applyView = (view: DesignView): void => {
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
readAlignmentDraft,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
|
||||
import { pageReplaying } from "../../B05_Profile/B05_Profile_UI_Profile_History";
|
||||
import {
|
||||
analysisTerrain,
|
||||
fetchRoutePlan,
|
||||
@@ -259,6 +260,9 @@ function createPagePipeline(
|
||||
/** 묶음이 가게에 마지막으로 쓴 관 · 구조물 · 쓰는 동안(메아리 — 다시 입력으로 안 받음). */
|
||||
let written: Facilities | null = null;
|
||||
let echo = false;
|
||||
/** 되돌리기 · 다시하기로 바뀐 노선(356-4) — follow 가 계획고 편집을 옮기지 않고 그 걸음 사진(세션 노선 0 칸)
|
||||
* 그대로. 바뀐 구간 첫 계획선 맞춤 · 계획고 옮김은 새 조작 때만. */
|
||||
let replay = false;
|
||||
|
||||
const savedLine = (): Vertex[] | null =>
|
||||
plan ? plan.planned.map(([x, y]): Vertex => [x, y]) : null;
|
||||
@@ -320,6 +324,9 @@ function createPagePipeline(
|
||||
savedLine: () => plan && { line: savedLine()!, curves: savedCurves() },
|
||||
}),
|
||||
follow: (ctx) => {
|
||||
const kept = replay && ctx.inputs.route ? readAlignmentDraft(DRAFT_ROUTE_ID) : null;
|
||||
replay = false;
|
||||
if (kept) return { alignment: kept }; // 관 · 구조물도 세션 그대로(가게 · 쓰기 그대로)
|
||||
const line = ctx.before.route?.line ?? savedLine() ?? [];
|
||||
const { pipes, structures } = ctx.inputs;
|
||||
const inputs = {
|
||||
@@ -567,6 +574,7 @@ function createPagePipeline(
|
||||
if (stored && same({ ...stored, rev: 0 }, { ...draft, rev: 0 })) draft = stored;
|
||||
}
|
||||
if (draft && !wasDraft && !held) held = holdNow();
|
||||
replay = pageReplaying();
|
||||
store.set("routeDraft", draft);
|
||||
if (!started) return start();
|
||||
const before = pipe.output("route")?.line ?? null;
|
||||
|
||||
@@ -145,8 +145,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
// 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도
|
||||
// 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고).
|
||||
bodyWrap.append(balanceBar, body, progress.root);
|
||||
// 유토곡선 — 패널 바닥에 붙는 오버레이 서브패널(2026-08-04 사용자 확정).
|
||||
// 종단도·테이블 배치는 건드리지 않고 그 위를 덮으며, 위 경계 리사이저로 높이를 조절한다.
|
||||
// 유토곡선 — 종단 칸 아래 자기 판(354-1) · 테이블 — 종단 칸 바닥 겹침 판.
|
||||
// 둘 다 위 경계 리사이저로 높이를 조절한다.
|
||||
// 서브패널 리사이저를 끄는 동안엔 메인 드래그와 같은 **경량 동기화**만 프레임마다 돌리고
|
||||
// (차트 SVG·테이블 재생성은 무거워 덜컹였다 — 2026-08-06 분석 원인 3), 전체 재구성은
|
||||
// 손을 뗀 뒤(clearDragFlags) 1회만 한다. 펼침·범례 토글 등 드래그 밖 알림은 전체 draw.
|
||||
@@ -155,7 +155,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
else draw();
|
||||
};
|
||||
// 유토곡선 · 테이블 오버레이 · 유토 배지는 `design_profile_overlays` 몫(700줄 한계).
|
||||
const { massHaul, tableOverlay } = createProfileOverlays({
|
||||
const { massHaul, tableOverlay, column } = createProfileOverlays({
|
||||
projectId,
|
||||
routeId: () => routeId,
|
||||
bodyWrap,
|
||||
@@ -192,7 +192,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
callbacks?.onStructureTypeAdd?.(chainage, typeId),
|
||||
),
|
||||
}) ?? null;
|
||||
content.append(bodyWrap);
|
||||
content.append(column);
|
||||
if (drainagePanel) content.append(drainagePanel.root);
|
||||
const drag = createProfileDrag({
|
||||
root,
|
||||
@@ -385,7 +385,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
|
||||
/**
|
||||
* 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지
|
||||
* 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면
|
||||
* 줄고 → ② 테이블이 최소까지 줄고(유토곡선은 자기 판 · 354-1) → ③ 둘 다 최소면
|
||||
* 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기
|
||||
* 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리).
|
||||
*
|
||||
@@ -395,7 +395,6 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
const heights = createHeightCascade({
|
||||
root,
|
||||
body,
|
||||
massHaul,
|
||||
tableOverlay,
|
||||
isMainDragging: drag.isMainDragging,
|
||||
isSubDragging: drag.isSubDragging,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* design_profile_overlays.ts
|
||||
* 종단도 바닥 오버레이 서브패널 — 유토곡선 · 테이블 · 유토 총괄 배지(213-3 · 700줄 한계로 뗌).
|
||||
* 만들어 `bodyWrap` 에 붙이고 종단 스크롤과 잇는 데까지 — 그리기 · 높이는 종단도 몫.
|
||||
* 종단도 겹침 판(테이블) · 종단 열 아래 유토곡선 판 · 유토 총괄 배지(213-3 · 700줄 한계로 뗌).
|
||||
* 만들어 붙이고 종단 스크롤과 잇는 데까지 — 그리기 · 높이는 종단도 몫.
|
||||
* 종단 열(`column`) = [종단 칸(`bodyWrap`), 유토곡선 판](354-1) — 종단도가 content 왼쪽에 붙인다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachMassHaulBadgeDrag, createMassHaulBadge } from "@util/common_util_mass_haul_badge";
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
export interface ProfileOverlaysDeps {
|
||||
projectId: string;
|
||||
routeId: () => number | null;
|
||||
/** 종단 열(요약 줄 · 본문 · 서클을 담은 칸) — 오버레이가 그 위를 덮는다. */
|
||||
/** 종단 칸(요약 줄 · 본문 · 서클을 담은 칸) — 표 겹침 판이 그 위를 덮는다. */
|
||||
bodyWrap: HTMLElement;
|
||||
/** 종단 스크롤러 — 오버레이 가로 스크롤을 이것과 맞춘다. */
|
||||
body: HTMLElement;
|
||||
@@ -54,10 +55,13 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) {
|
||||
},
|
||||
haulPrefetch,
|
||||
);
|
||||
bodyWrap.append(massHaul.overlay, massHaul.handle);
|
||||
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
|
||||
// 유토곡선 = 종단 칸 아래 자기 판(354-1 · 배수유역도처럼) — 종단 [창 분할]에도 함께 감(content 안).
|
||||
const column = document.createElement("div");
|
||||
column.className = "b05-route-profile__column";
|
||||
column.append(bodyWrap, massHaul.overlay);
|
||||
// 판의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
|
||||
massHaul.attachScrollSync(body);
|
||||
// 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시).
|
||||
// 테이블은 종단 칸 바닥 겹침 판(2026-08-05 사용자 지시).
|
||||
const tableOverlay = createProfileTableOverlay(onChange);
|
||||
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
|
||||
tableOverlay.attachScrollSync(body);
|
||||
@@ -65,5 +69,5 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) {
|
||||
// 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다.
|
||||
body.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
return { massHaul, tableOverlay };
|
||||
return { massHaul, tableOverlay, column };
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export function attachProfilePopout(
|
||||
});
|
||||
}
|
||||
|
||||
/** 유토곡선 · 배수유역 판에 [창 분할] 단추를 단다 — 유토 판 좌상단 · 배수 머리줄 맨 앞(계획서 325-3). */
|
||||
/** 유토곡선 · 배수유역 판에 [창 분할] 단추를 단다 — 두 판 머리줄 맨 앞(계획서 325-3 · 354-1). */
|
||||
export function attachSubPanelPopouts(profile: DesignProfile): void {
|
||||
const subButton = (title: string): HTMLButtonElement => {
|
||||
const button = document.createElement("button");
|
||||
@@ -141,23 +141,20 @@ export function attachSubPanelPopouts(profile: DesignProfile): void {
|
||||
};
|
||||
|
||||
const massOverlay = profile.root.querySelector<HTMLElement>(".b05-profile__masshaul-overlay");
|
||||
const massHandle = profile.root.querySelector<HTMLElement>(".b05-profile__masshaul-handle");
|
||||
if (massOverlay && massHandle) {
|
||||
const massHead = massOverlay?.querySelector<HTMLElement>(".b05-profile__masshaul-head");
|
||||
if (massOverlay && massHead) {
|
||||
const button = subButton("유토곡선 창 분할");
|
||||
massOverlay.append(button);
|
||||
// 판 머리줄 맨 앞 — 접어도 남아 닿는다(354-1).
|
||||
massHead.prepend(button);
|
||||
const column = massOverlay.parentElement;
|
||||
attachPopout(profile, {
|
||||
piece: massOverlay,
|
||||
button,
|
||||
name: "aislo-masshaul-popout",
|
||||
features: "popup,width=1400,height=420",
|
||||
docTitle: "유토곡선 — Aislo",
|
||||
// 제자리 = 유토 손잡이 바로 앞(종단 본문 바닥 겹침 판).
|
||||
home: () => massHandle.before(massOverlay),
|
||||
onMove: (popped) => {
|
||||
// 뗀 동안 메인 손잡이는 숨김 · 같이 줄이기 인라인 높이는 비움(새 창은 창 높이를 다 씀).
|
||||
massHandle.hidden = popped;
|
||||
massOverlay.style.height = "";
|
||||
},
|
||||
// 제자리 = 종단 열 끝(종단 칸 아래 자기 판).
|
||||
home: () => column?.append(massOverlay),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const TEXT = {
|
||||
freezeAt: "지금 칸 기준 고정",
|
||||
unfreeze: "틀 고정 풀기",
|
||||
insert: "칸 넣기…(Ctrl+Shift++)",
|
||||
remove: "칸 지우기…(Ctrl+-)",
|
||||
remove: "칸 삭제…(Ctrl+-)",
|
||||
split: "텍스트 나누기…",
|
||||
dedupe: "중복된 항목 제거…",
|
||||
series: "연속 데이터 채우기…",
|
||||
|
||||
@@ -249,7 +249,7 @@ export function createCondFmtPanel(ctx: SpreadsheetContext): PartHandle {
|
||||
moveCondFmtRule(ctx, rule.id, 1);
|
||||
render();
|
||||
});
|
||||
const del = createButton({ label: "지움", variant: "danger" });
|
||||
const del = createButton({ label: "삭제", variant: "danger" });
|
||||
del.addEventListener("click", () => {
|
||||
removeCondFmtRule(ctx, rule.id);
|
||||
render();
|
||||
|
||||
@@ -44,7 +44,7 @@ const TEXT = {
|
||||
Then: ["→", "→"],
|
||||
Otherwise: ["아니면 →", "Otherwise →"],
|
||||
AddElseIf: ["+ 아니면 만약", "+ Else if"],
|
||||
Remove: ["지움", "Remove"],
|
||||
Remove: ["삭제", "Remove"],
|
||||
Make: ["만들기", "Make"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Close: ["닫기", "Close"],
|
||||
|
||||
@@ -66,7 +66,7 @@ const TEXT = {
|
||||
left: "왼",
|
||||
right: "오른",
|
||||
fillColor: "채움색",
|
||||
clear: "지움",
|
||||
clear: "삭제",
|
||||
protectNote: "칸 잠금 · 숨김은 자리만 둠(시트 보호 자체가 아직 없음)",
|
||||
lock: "잠금",
|
||||
hidden: "수식 숨김",
|
||||
|
||||
@@ -190,7 +190,7 @@ export function createNameManagerPanel(ctx: SpreadsheetContext): PartHandle {
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
const del = createButton({ label: "지움", variant: "danger" });
|
||||
const del = createButton({ label: "삭제", variant: "danger" });
|
||||
del.addEventListener("click", () => {
|
||||
deleteName(ctx, 이름);
|
||||
render();
|
||||
|
||||
@@ -76,7 +76,7 @@ export function createSheetExtraPanel(ctx: SpreadsheetContext): PartHandle {
|
||||
colorInput.addEventListener("change", () =>
|
||||
setSheetTabColor(ctx, sheet.id, colorInput.value),
|
||||
);
|
||||
const colorClear = createButton({ label: "색 지움", variant: "ghost" });
|
||||
const colorClear = createButton({ label: "색 삭제", variant: "ghost" });
|
||||
colorClear.addEventListener("click", () => {
|
||||
setSheetTabColor(ctx, sheet.id, null);
|
||||
render();
|
||||
|
||||
@@ -25,7 +25,7 @@ const TEXT = {
|
||||
Strike: ["취소선", "Strikethrough"],
|
||||
TextColor: ["글자색", "Text color"],
|
||||
FillColor: ["채움색", "Fill color"],
|
||||
ColorClear: ["지움", "Clear"],
|
||||
ColorClear: ["색 삭제", "Clear color"],
|
||||
HAlign: ["가로 정렬", "Horizontal align"],
|
||||
VAlign: ["세로 정렬", "Vertical align"],
|
||||
Align_general: ["일반", "General"],
|
||||
@@ -70,7 +70,7 @@ const TEXT = {
|
||||
],
|
||||
SheetAdd: ["시트 추가", "Add sheet"],
|
||||
SheetRename: ["이름 바꾸기", "Rename"],
|
||||
SheetDelete: ["지우기", "Delete"],
|
||||
SheetDelete: ["삭제", "Delete"],
|
||||
SheetDuplicate: ["복사", "Duplicate"],
|
||||
SheetRenamePrompt: ["새 시트 이름", "New sheet name"],
|
||||
SheetDeleteConfirm: [
|
||||
@@ -84,9 +84,9 @@ const TEXT = {
|
||||
MenuCopy: ["복사", "Copy"],
|
||||
MenuPasteHint: ["Ctrl+V 를 쓸 것", "Use Ctrl+V"],
|
||||
MenuInsertRow: ["행 넣기", "Insert row"],
|
||||
MenuDeleteRow: ["행 지우기", "Delete row"],
|
||||
MenuDeleteRow: ["행 삭제", "Delete row"],
|
||||
MenuInsertCol: ["열 넣기", "Insert column"],
|
||||
MenuDeleteCol: ["열 지우기", "Delete column"],
|
||||
MenuDeleteCol: ["열 삭제", "Delete column"],
|
||||
MenuHide: ["숨기기", "Hide"],
|
||||
MenuUnhide: ["숨김 취소", "Unhide"],
|
||||
MenuClearContent: ["내용 지우기", "Clear content"],
|
||||
|
||||
@@ -183,7 +183,7 @@ export function createValidationPanel(ctx: SpreadsheetContext): PartHandle {
|
||||
const label = el("span", {
|
||||
text: `${rule.범위.map(rangeToA1).join(", ")} — ${kindText(rule.규칙)}(${rule.어기면})`,
|
||||
});
|
||||
const del = createButton({ label: "지움", variant: "danger" });
|
||||
const del = createButton({ label: "삭제", variant: "danger" });
|
||||
del.addEventListener("click", () => {
|
||||
removeValidationRule(ctx, rule.id);
|
||||
render();
|
||||
|
||||
@@ -58,7 +58,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
|
||||
placeholder: "도로명·지번·건물명 일부 (예: 판교역로 235)",
|
||||
});
|
||||
const findBtn = createButton({
|
||||
label: "주소 찾기",
|
||||
label: "주소 검색",
|
||||
variant: "ghost",
|
||||
onClick: () => void runSearch(),
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ export function createAssetField(
|
||||
|
||||
const fallback = options.fallback ?? null;
|
||||
const reset = createButton({
|
||||
label: "기본으로",
|
||||
label: "기본값 복원",
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
selected = null;
|
||||
@@ -173,7 +173,7 @@ function createSignaturePad(): { root: HTMLDivElement; toFile: () => Promise<Fil
|
||||
canvas.addEventListener("pointercancel", stop);
|
||||
|
||||
const clear = createButton({
|
||||
label: "지우기",
|
||||
label: "삭제",
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
context?.clearRect(0, 0, canvas.width, canvas.height);
|
||||
@@ -312,7 +312,7 @@ function openAssetPickerModal(
|
||||
);
|
||||
const pad = kind === "SIGNATURE" ? createSignaturePad() : null;
|
||||
const add = createButton({
|
||||
label: "파일 올리고 이 프로젝트에 쓰기",
|
||||
label: "파일 업로드",
|
||||
onClick: async () => {
|
||||
if (!label.input.value.trim()) return label.setError("이름을 넣어 주세요.");
|
||||
const chosen = file.input.files?.[0] ?? (await pad?.toFile()) ?? null;
|
||||
|
||||
@@ -461,7 +461,7 @@ export function openCreateCompanyModal(): void {
|
||||
const matches = document.createElement("div");
|
||||
matches.className = "b01-dashboard__actions";
|
||||
const findBtn = createButton({
|
||||
label: "이미 있는 회사인지 찾기",
|
||||
label: "회사 검색",
|
||||
variant: "ghost",
|
||||
onClick: async function onB01_Company_Duplicate_Click() {
|
||||
matches.innerHTML = "";
|
||||
@@ -589,7 +589,7 @@ export function openAddMemberModal(
|
||||
companyId?: number | null,
|
||||
): void {
|
||||
const query = createInputField({
|
||||
label: "이름 또는 이메일로 찾기",
|
||||
label: "이름 · 이메일 검색",
|
||||
placeholder: "두 글자 이상",
|
||||
});
|
||||
const results = document.createElement("div");
|
||||
@@ -632,7 +632,7 @@ export function openAddMemberModal(
|
||||
});
|
||||
|
||||
const invite = createButton({
|
||||
label: "가입 안내 메일 보내기",
|
||||
label: "초대 메일 발송",
|
||||
variant: "ghost",
|
||||
onClick: async function onB01_Member_Invite_Click() {
|
||||
const text = query.input.value.trim();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { contourAssetUrl, fetchCachedJson } from "../A00_Common/b_asset_cache";
|
||||
import { contourMaterials, isMajorContour } from "@ui/ui_template_contour3d";
|
||||
|
||||
/** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다(분리 전 상수 그대로). */
|
||||
const MAX_CONTOUR_LABELS = 40;
|
||||
@@ -93,7 +94,7 @@ export async function loadContourLinesInto(
|
||||
const points = transform(c.coordinates);
|
||||
if (points.length < 2) return;
|
||||
|
||||
const isMajor = c.level % (interval * 5) === 0;
|
||||
const isMajor = isMajorContour(c.level);
|
||||
const bucket = isMajor ? majorPoints : minorPoints;
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
bucket.push(points[i], points[i + 1]);
|
||||
@@ -156,18 +157,15 @@ export async function loadContourLinesInto(
|
||||
ctx.setLabelsDirty(true);
|
||||
}
|
||||
|
||||
// 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다.
|
||||
// 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다 — 주곡선 = 5 m 고정 · 1 m 는
|
||||
// 옅게(평면 그림과 같은 색 토큰 · 353-3).
|
||||
const materials = contourMaterials();
|
||||
[
|
||||
{ points: minorPoints, color: 0xf59e0b },
|
||||
{ points: majorPoints, color: 0xd97706 },
|
||||
].forEach(({ points, color }) => {
|
||||
{ points: minorPoints, material: materials.minor },
|
||||
{ points: majorPoints, material: materials.major },
|
||||
].forEach(({ points, material }) => {
|
||||
if (points.length === 0) return;
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const material = new THREE.LineBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
});
|
||||
ctx.contourGroup.add(new THREE.LineSegments(geometry, material));
|
||||
});
|
||||
|
||||
|
||||
@@ -23,16 +23,29 @@ import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_type
|
||||
/** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */
|
||||
export type AnalysisTerrain = "las" | "sheet";
|
||||
|
||||
/** 고른 분석 지형 — 화면 상태 보관소 pref 통(노선 편집 고정과 같은 자리). 없으면 `fallback`
|
||||
* (모달이 열 때 설계 지형으로 채움). [확인] 때 이 값이 설계 지형이 됨(PLAN 133-2). */
|
||||
/** 편집기가 열 때 알리는 지형 사정(352-3) — 저장하지 않음. 고를 수 있나(라이다 · 수치지형도 둘) · 설계 지형. */
|
||||
const terrainContext = new Map<string, { choosable: boolean; design: AnalysisTerrain }>();
|
||||
|
||||
export const setTerrainContext = (
|
||||
projectId: string,
|
||||
choosable: boolean,
|
||||
design: AnalysisTerrain,
|
||||
): void => void terrainContext.set(projectId, { choosable, design });
|
||||
|
||||
/** 지금 쓰는 분석 지형 — 하나뿐인 프로젝트는 그것 · 둘이면 사용자가 고른 값(화면 상태 보관소 pref 통 ·
|
||||
* 노선 편집 고정과 같은 자리), 고른 적 없으면 데이터대로(= 설계 지형 · 라이다가 있으면 라이다).
|
||||
* 고른 값은 [저장] 때 설계 지형이 됨(PLAN 133-2). */
|
||||
export const analysisTerrain = (
|
||||
projectId: string,
|
||||
fallback: AnalysisTerrain = "las",
|
||||
): AnalysisTerrain => {
|
||||
const context = terrainContext.get(projectId);
|
||||
if (context && !context.choosable) return context.design;
|
||||
const saved = readState<string>("routeedit-terrain", projectId);
|
||||
return saved === "sheet" || saved === "las" ? saved : fallback;
|
||||
return saved === "sheet" || saved === "las" ? saved : (context?.design ?? fallback);
|
||||
};
|
||||
|
||||
/** 사용자가 고를 때만 기억 — 열 때 기본값은 쓰지 않음. */
|
||||
export const setAnalysisTerrain = (projectId: string, terrain: AnalysisTerrain): void =>
|
||||
writeState("routeedit-terrain", terrain, projectId);
|
||||
|
||||
|
||||
@@ -10,12 +10,11 @@
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
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;
|
||||
/** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게.
|
||||
/** 접힌 테이블 손잡이가 앉을 본문 아래 전용 띠 — 곡선 행 클릭을 가리지 않게.
|
||||
* 구조물 알약 레인이 그 위에 붙은 뒤로는 사이가 떠 보여 절반으로 줄였다
|
||||
* (2026-08-17 사용자 지시 2). */
|
||||
const MASSHAUL_HANDLE_GUTTER_PX = 13;
|
||||
@@ -93,7 +92,7 @@ export function createElevationHold(
|
||||
};
|
||||
}
|
||||
|
||||
/** 겹침 판(유토곡선 · 표) 중 끌기가 보는 것만. */
|
||||
/** 겹침 판(표) 중 끌기가 보는 것만. */
|
||||
interface SubPanel {
|
||||
overlay: HTMLElement;
|
||||
commitHeight: (height: number) => void;
|
||||
@@ -102,7 +101,8 @@ interface SubPanel {
|
||||
export interface ProfileDragContext {
|
||||
root: HTMLElement;
|
||||
body: HTMLElement;
|
||||
massHaul: SubPanel;
|
||||
/** 유토곡선 판 — 그 리사이저 끌기 출처만 본다(자기 판이라 높이 나눔에 안 낌 · 354-1). */
|
||||
massHaul: { overlay: HTMLElement };
|
||||
tableOverlay: SubPanel & { isOpen: () => boolean };
|
||||
/** 손을 뗀 뒤 한 번 다시 세운다. */
|
||||
draw: () => void;
|
||||
@@ -112,7 +112,7 @@ export interface ProfileDrag {
|
||||
resizerRoot: HTMLElement;
|
||||
isMainDragging: () => boolean;
|
||||
isSubDragging: () => boolean;
|
||||
mainDragRef: () => { chart: number; mass: number; table: number } | null;
|
||||
mainDragRef: () => { chart: number; table: number } | null;
|
||||
isMainDragCooldown: () => boolean;
|
||||
clearMainDragCooldown: () => void;
|
||||
/** 전체 재구성을 120ms 뒤로 미룬다(연속 호출은 마지막 한 번만). */
|
||||
@@ -133,23 +133,21 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
|
||||
};
|
||||
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
|
||||
// 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다.
|
||||
// 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2).
|
||||
const massDocked = (): boolean => !massHaul.overlay.hidden && root.contains(massHaul.overlay);
|
||||
const heightResizer = createPanelResizer({
|
||||
axis: "vertical",
|
||||
target: root,
|
||||
cssVar: "--b05-profile-height",
|
||||
direction: -1,
|
||||
// 동적 하한: 종단 최소 + 열린 서브패널들의 최소 높이 합(2026-08-05 사용자 확정).
|
||||
// 동적 하한: 종단 최소 + 열린 표 최소 높이(2026-08-05 사용자 확정) — 유토곡선 판 높이는
|
||||
// chrome(뿌리 − 본문)에 이미 들어 있다(354-1).
|
||||
// 패널을 아래로 끌면 종단이 먼저 줄고 → 서브패널들이 같이 줄고(draw의 캐스케이드) →
|
||||
// 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단).
|
||||
min: () => {
|
||||
const chrome = Math.max(0, root.offsetHeight - body.clientHeight);
|
||||
const massMin = massDocked() ? MASSHAUL_MIN_HEIGHT : 0;
|
||||
const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0;
|
||||
return Math.max(
|
||||
MIN_PANEL_HEIGHT,
|
||||
chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin,
|
||||
chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + tableMin,
|
||||
);
|
||||
},
|
||||
// 상한 = 가운데 칸 높이의 90%(위 칸 3D · 횡단 카드가 다 가려지지 않게) — Heights 와 한 식
|
||||
@@ -166,19 +164,14 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
|
||||
* 플래그가 이미 풀린 채 돌아, 드래그 중 금지했던 자동 확대가 손 떼는 순간 발동해
|
||||
* 포인터가 정한 높이를 되돌렸다(2026-08-06 분석 원인 4). draw()가 끝나며 푼다. */
|
||||
let mainDragCooldown = false;
|
||||
/** 메인 드래그 시작 시점의 3영역(종단·유토곡선·테이블) 실제 높이 — 비례 연동 기준.
|
||||
/** 메인 드래그 시작 시점의 두 영역(종단·테이블) 실제 높이 — 비례 연동 기준.
|
||||
* 드래그 중 이 비율대로 같이 늘리고 줄인다(2026-08-06 사용자 확정). */
|
||||
let mainDragRef: { chart: number; mass: number; table: number } | null = null;
|
||||
let mainDragRef: { chart: number; table: number } | null = null;
|
||||
heightResizer.root.addEventListener("pointerdown", () => {
|
||||
mainPanelDragging = true;
|
||||
const massH = massDocked() ? massHaul.overlay.offsetHeight : 0;
|
||||
const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0;
|
||||
mainDragRef = {
|
||||
chart: Math.max(
|
||||
MIN_CHART_HEIGHT,
|
||||
body.clientHeight - massH - tableH - MASSHAUL_HANDLE_GUTTER_PX,
|
||||
),
|
||||
mass: massH,
|
||||
chart: Math.max(MIN_CHART_HEIGHT, body.clientHeight - tableH - MASSHAUL_HANDLE_GUTTER_PX),
|
||||
table: tableH,
|
||||
};
|
||||
});
|
||||
@@ -193,9 +186,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
|
||||
// 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정
|
||||
// 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전
|
||||
// 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다).
|
||||
if (massDocked()) massHaul.commitHeight(massHaul.overlay.offsetHeight);
|
||||
if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight);
|
||||
massHaul.overlay.style.height = "";
|
||||
tableOverlay.overlay.style.height = "";
|
||||
}
|
||||
mainDragRef = null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Heights.ts
|
||||
* 종단 패널의 높이 배분 — 그래프·유토곡선·테이블이 한 패널 높이를 나눠 갖는 규칙.
|
||||
* 종단 패널의 높이 배분 — 그래프·테이블이 한 본문 높이를 나눠 갖는 규칙.
|
||||
* 유토곡선은 종단 열 아래 자기 판이라 본문 높이에서 이미 빠져 있다 — 나눔에 안 낀다(354-1).
|
||||
*
|
||||
* 패널 본체(B05_Profile_UI_Profile_Panel)가 700줄 한계에 닿아 분리했다.
|
||||
* 판정 기준은 **저장 높이**(리사이저 변수)이며 화면 높이가 아니다 — 화면 높이로 재면
|
||||
@@ -8,14 +9,13 @@
|
||||
* 드래그 플래그·상세 데이터 유무는 본체가 계속 들고 있고 여기서는 접근자로만 읽는다.
|
||||
* ========================================================================== */
|
||||
|
||||
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;
|
||||
/** 접힌 유토곡선 손잡이가 차지하는 바닥 여백(px). */
|
||||
/** 접힌 테이블 손잡이가 차지하는 바닥 여백(px). */
|
||||
const MASSHAUL_HANDLE_GUTTER_PX = 13;
|
||||
/** 패널 자동 확대 상한 — 메인 리사이저 max와 같은 식이어야 한다(부모 높이 비율). */
|
||||
const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
||||
@@ -32,21 +32,15 @@ export interface HeightCascadeDeps {
|
||||
root: HTMLElement;
|
||||
/** 본문(그래프+서브패널이 들어가는 스크롤 영역). */
|
||||
body: HTMLElement;
|
||||
massHaul: {
|
||||
overlay: HTMLElement;
|
||||
desiredHeight: () => number;
|
||||
syncHandle: () => void;
|
||||
};
|
||||
tableOverlay: {
|
||||
overlay: HTMLElement;
|
||||
isOpen: () => boolean;
|
||||
desiredHeight: () => number;
|
||||
setBottomOffset: (value: number) => void;
|
||||
};
|
||||
isMainDragging: () => boolean;
|
||||
isSubDragging: () => boolean;
|
||||
/** 메인 드래그 시작 시점의 3영역 비율(없으면 null). */
|
||||
mainDragRef: () => { chart: number; mass: number; table: number } | null;
|
||||
/** 메인 드래그 시작 시점의 두 영역 비율(없으면 null). */
|
||||
mainDragRef: () => { chart: number; table: number } | null;
|
||||
/** 손 뗀 직후 첫 재구성까지는 자동 확대를 막는다. */
|
||||
isMainDragCooldown: () => boolean;
|
||||
/** 상세 데이터가 아직 없으면 경량 동기화를 건너뛴다. */
|
||||
@@ -67,21 +61,17 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
|
||||
// 구조물 알약 레인이 그래프 아래 한 줄을 차지한다 — 그만큼 먼저 떼어 놓고 나눈다
|
||||
// (2026-08-17 사용자 지시 6: 그래프가 밀려도 되지만 여유는 최소).
|
||||
const available = Math.max(120, deps.body.clientHeight - STRUCTURE_LANE_HEIGHT_PX);
|
||||
// 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2).
|
||||
const massOpen = !deps.massHaul.overlay.hidden && deps.root.contains(deps.massHaul.overlay);
|
||||
const tableOpen = deps.tableOverlay.isOpen();
|
||||
// 판정 기준은 항상 **저장 높이**(리사이저 변수·기본값)다 — 임시 축소가 반영된 화면
|
||||
// 높이(offsetHeight)로 재면 "줄임 → 충분해 보임 → 풀림 → 부족 → 다시 줄임"이
|
||||
// 호출마다 번갈아 도는 진동이 되고, 풀리는 호출의 deficit 처리가 메인 패널을
|
||||
// 멋대로 키운다(2026-08-06 분석: 튐·자동 확대의 핵심 원인). 저장 높이 기준이면
|
||||
// 몇 번을 호출해도 같은 답이 나온다(멱등).
|
||||
const massDesired = massOpen ? deps.massHaul.desiredHeight() : 0;
|
||||
const tableDesired = tableOpen ? deps.tableOverlay.desiredHeight() : 0;
|
||||
let massHeight = massDesired;
|
||||
let tableOverlayHeight = tableDesired;
|
||||
const budget = Math.max(0, available - MIN_CHART_HEIGHT - MASSHAUL_HANDLE_GUTTER_PX);
|
||||
// ── 메인 드래그 중: 3영역 비례 연동(2026-08-06 사용자 확정) ─────────────
|
||||
// 드래그 시작 시점 비율(deps.mainDragRef())대로 종단·유토곡선·테이블을 같이 늘리고
|
||||
// ── 메인 드래그 중: 비례 연동(2026-08-06 사용자 확정) ─────────────
|
||||
// 드래그 시작 시점 비율(deps.mainDragRef())대로 종단·테이블을 같이 늘리고
|
||||
// 줄인다. 최소에 닿은 영역은 거기서 멈추고 남은 영역끼리 다시 비례 배분한다.
|
||||
// 손을 떼면 clearDragFlags가 이 결과를 저장 높이로 확정한다.
|
||||
const dragRef = deps.mainDragRef();
|
||||
@@ -89,15 +79,12 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
|
||||
const content = Math.max(0, available - MASSHAUL_HANDLE_GUTTER_PX);
|
||||
const items = [
|
||||
{ key: "chart", ref: Math.max(1, dragRef.chart), min: MIN_CHART_HEIGHT },
|
||||
...(massOpen
|
||||
? [{ key: "mass", ref: Math.max(1, dragRef.mass), min: MASSHAUL_MIN_HEIGHT }]
|
||||
: []),
|
||||
...(tableOpen
|
||||
? [{ key: "table", ref: Math.max(1, dragRef.table), min: TABLE_OVERLAY_MIN_HEIGHT }]
|
||||
: []),
|
||||
];
|
||||
// 고정 → 재배분 반복: 축소 배율로 최소를 뚫는 항목을 최소에 고정하고, 남은
|
||||
// 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 3개라 최대 3회에 끝난다.
|
||||
// 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 2개라 최대 2회에 끝난다.
|
||||
const out: Record<string, number> = {};
|
||||
let pool = content;
|
||||
let active = items;
|
||||
@@ -116,50 +103,34 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
|
||||
active = active.filter((item) => !pinned.includes(item));
|
||||
}
|
||||
// 서브패널 상한(개별 리사이저 max와 같은 비율)은 안전상 유지 — 넘치면 종단이 흡수.
|
||||
massHeight = massOpen ? Math.min(out.mass ?? 0, Math.round(available * 0.8)) : 0;
|
||||
tableOverlayHeight = tableOpen ? Math.min(out.table ?? 0, Math.round(available * 0.75)) : 0;
|
||||
if (massOpen) deps.massHaul.overlay.style.height = `${massHeight}px`;
|
||||
if (tableOpen) deps.tableOverlay.overlay.style.height = `${tableOverlayHeight}px`;
|
||||
// 손잡이는 바뀐 인라인 높이의 위 경계를 즉시 따라간다.
|
||||
deps.massHaul.syncHandle();
|
||||
deps.tableOverlay.setBottomOffset(massHeight);
|
||||
return {
|
||||
chartHeight: Math.max(MIN_CHART_HEIGHT, content - massHeight - tableOverlayHeight),
|
||||
};
|
||||
return { chartHeight: Math.max(MIN_CHART_HEIGHT, content - tableOverlayHeight) };
|
||||
}
|
||||
if (deps.isSubDragging()) {
|
||||
// 서브패널 리사이저를 끄는 중이면 그 높이가 **사용자 의도**다 — 임시 축소(인라인)를
|
||||
// 걷어 변수(드래그 값)가 그대로 보이게 하고, 자리가 모자라면 아래 deficit 처리로
|
||||
// 메인 패널을 키운다. 변수는 드래그마다 갱신되므로 저장 높이 = 드래그 값이다.
|
||||
deps.massHaul.overlay.style.height = "";
|
||||
deps.tableOverlay.overlay.style.height = "";
|
||||
} else if (massDesired + tableDesired > budget) {
|
||||
// ② 같이 줄이기 — 줄일 수 있는 여유분에 비례해 축소, 각자 최소 높이 하한.
|
||||
} else if (tableDesired > budget) {
|
||||
// ② 줄이기 — 줄일 수 있는 여유분만큼 축소, 최소 높이 하한.
|
||||
// 자리가 다시 늘면 ratio가 매끄럽게 0으로 줄어 저장 높이로 연속 복귀한다.
|
||||
const massMin = massOpen ? MASSHAUL_MIN_HEIGHT : 0;
|
||||
const tableMin = tableOpen ? TABLE_OVERLAY_MIN_HEIGHT : 0;
|
||||
const shrinkable = massDesired - massMin + (tableDesired - tableMin);
|
||||
const over = massDesired + tableDesired - budget;
|
||||
const shrinkable = tableDesired - tableMin;
|
||||
const over = tableDesired - budget;
|
||||
const ratio = shrinkable > 0 ? Math.min(1, over / shrinkable) : 1;
|
||||
massHeight = Math.round(massDesired - (massDesired - massMin) * ratio);
|
||||
tableOverlayHeight = Math.round(tableDesired - (tableDesired - tableMin) * ratio);
|
||||
// 임시 축소는 인라인 높이로만 — 리사이저 저장값(--변수·세션)은 건드리지 않아
|
||||
// 패널을 다시 키우면 원래 높이로 돌아온다.
|
||||
if (massOpen) deps.massHaul.overlay.style.height = `${massHeight}px`;
|
||||
if (tableOpen) deps.tableOverlay.overlay.style.height = `${tableOverlayHeight}px`;
|
||||
} else {
|
||||
// 공간 충분 — 임시 축소 해제(저장 높이로 복귀).
|
||||
deps.massHaul.overlay.style.height = "";
|
||||
deps.tableOverlay.overlay.style.height = "";
|
||||
}
|
||||
// 접힌 유토곡선 손잡이는 바닥 고정(순서상 테이블 아래에서 나옴), 접힌 테이블 손잡이는
|
||||
// 열린 유토곡선 위 경계(TableOverlay.syncHandlePosition).
|
||||
deps.tableOverlay.setBottomOffset(massHeight);
|
||||
// ③' 최소까지 줄여도 모자라면(서브패널 펼침·서브패널 드래그로 자리가 부족한 경우)
|
||||
// 메인 패널을 키운다. 단 **메인 패널을 끄는 중과 손 뗀 직후 첫 재구성까지**는 절대
|
||||
// 안 된다 — 포인터가 정한 높이를 되돌려 서로 밀고 당기는 진동이 생긴다.
|
||||
const deficit =
|
||||
MIN_CHART_HEIGHT + massHeight + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available;
|
||||
const deficit = MIN_CHART_HEIGHT + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available;
|
||||
if (deficit > 1 && allowGrow && !deps.isMainDragging() && !deps.isMainDragCooldown()) {
|
||||
// 상한은 메인 리사이저 max와 **같은 식**이어야 한다 — 예전 window 92% 상한은
|
||||
// 리사이저 상한(부모 90%)보다 높아, 자동 확대 직후 손잡이를 잡는 순간 낮은
|
||||
@@ -174,7 +145,7 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
|
||||
return {
|
||||
chartHeight: Math.max(
|
||||
MIN_CHART_HEIGHT,
|
||||
available - massHeight - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX,
|
||||
available - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@ export function setPageHistory(history: ProfileHistory): void {
|
||||
active = history;
|
||||
}
|
||||
|
||||
/** 되돌리기 · 다시하기가 세션을 되쓰고 화면을 되살리는 중인가 — 그 사이 바뀐 노선은 새 조작이 아님(356-4). */
|
||||
let replaying = false;
|
||||
export const pageReplaying = (): boolean => replaying;
|
||||
|
||||
export type PageHistoryEvent = "undo" | "redo";
|
||||
interface PageHistoryHooks {
|
||||
/** 세션을 되쓴 뒤 — 자기 화면 값을 다시 읽음(사진첩이 막고 있는 동안이라 쌓이지 않음). */
|
||||
@@ -193,7 +197,7 @@ export function createProfileHistory(onRestore: () => void, limit = 100): Profil
|
||||
if (index < 0 || index >= stack.length) return false;
|
||||
const kind: PageHistoryEvent = index < cursor ? "undo" : "redo";
|
||||
cursor = index;
|
||||
restoring = true;
|
||||
restoring = replaying = true;
|
||||
restoreSnapshot(stack[cursor]);
|
||||
try {
|
||||
onRestore();
|
||||
@@ -201,7 +205,7 @@ export function createProfileHistory(onRestore: () => void, limit = 100): Profil
|
||||
// 되살리며 다시 쓴 같은 값(정리된 꼴)을 이 걸음으로 — 다음 찍기가 헛걸음을 안 만듦.
|
||||
stack[cursor] = takeSnapshot(stack[cursor]);
|
||||
} finally {
|
||||
restoring = false;
|
||||
restoring = replaying = false;
|
||||
}
|
||||
changed();
|
||||
return true;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_MassHaul.ts
|
||||
* B05 유토곡선 — 종단면 패널 바닥에 붙는 **오버레이 서브패널**.
|
||||
* B05 유토곡선 — 종단 열 아래 **자기 판**(354-1 · 배수유역도처럼 손잡이 · 높이 · 접기 따로).
|
||||
*
|
||||
* ── B06과 같은 그림이어야 한다 (2026-08-03 사용자 확정) ──────────────
|
||||
* 이 프로그램은 파일 입력 시 기본 설정값으로 B06 몫까지 미리 계산해 둔다 — 종횡단 상세
|
||||
* 조회(`fetchSectionDetail`)가 지정 설계 없는 측점에 기본 설계를 즉석 계산해 얹으므로
|
||||
* (`_attach_default_designs`), B05 진입 시점에 이미 **횡단 단면적 기반 정식 곡선**을 그릴
|
||||
* 수 있다. 그래서 B06과 똑같이 `computeMassHaulSeries`(횡단 기준 + 종단 개략 비교)를 쓰고,
|
||||
* 수 있다. 그래서 B06과 똑같이 `computeMassHaulSeries`(횡단 기준만 · 종단 기준 숨김 354-2)를 쓰고,
|
||||
* 범례·토량 분배·balloon·요약도 전부 공용 모듈 그대로 쓴다.
|
||||
*
|
||||
* 표시 토글 sessionStorage 키도 B06과 **공유**한다 — 두 화면은 같은 그림의 두 창이라
|
||||
@@ -14,12 +14,11 @@
|
||||
* 같은 영구저장소(`longitudinal_sections.data.mass_haul`)를 읽으므로, B06에서 횡단을
|
||||
* 고쳐 임시저장/확정하면 B05는 재조회만으로 같은 곡선을 받는다.
|
||||
*
|
||||
* ── 배치 (2026-08-04 사용자 확정: 오버레이 전환) ─────────────────────
|
||||
* 예전에는 펼치면 종단 그래프와 세로 1:1 분할 + 도면 테이블 숨김이었다. 이제는 종단도·
|
||||
* 테이블 배치를 **건드리지 않고**, 패널 바닥에 붙는 오버레이가 그 위를 덮는다. 높이는
|
||||
* 오버레이 위 경계의 리사이저(메인 하단 패널과 같은 `createPanelResizer`)로 조절하고
|
||||
* 세션 동안 유지한다. 곡선 SVG는 오버레이 안 자체 가로 스크롤러에 들어가며, 종단
|
||||
* 스크롤러와 scrollLeft를 양방향 동기화해 측점 세로선이 어긋나지 않게 한다.
|
||||
* ── 배치 (354-1 · 요구 235-7 「유토곡선도 분리」) ─────────────────────
|
||||
* 종단 열(그래프 · 표) 아래 칸에 따로 선다 — 종단도 · 표 위를 덮지 않고 높이 나눔 · 표 쌓기에도
|
||||
* 안 낀다. 높이는 판 위 경계 리사이저(`createPanelResizer`)로 조절하고 세션 동안 유지한다.
|
||||
* 머리줄([창 분할] · 접기 손잡이)은 접어도 남는다. 곡선 SVG는 판 안 자체 가로 스크롤러에
|
||||
* 들어가며, 종단 스크롤러와 scrollLeft를 양방향 동기화해 측점 세로선이 어긋나지 않게 한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
@@ -56,7 +55,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
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 { attachWheelHorizontalScroll, linkScrollLeft } from "./B05_Profile_UI_Profile_Wheel";
|
||||
import { frameOf } from "../common_util/common_util_view_window";
|
||||
|
||||
/** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */
|
||||
@@ -65,12 +64,10 @@ import { frameOf } from "../common_util/common_util_view_window";
|
||||
const OVERLAY_HEIGHT_KEY = stateKey("masshaul-height") ?? "";
|
||||
/** 오버레이 높이 하한/기본값(px). 곡선 + 요약 막대가 읽히는 최소 크기다. */
|
||||
const OVERLAY_MIN_HEIGHT = 160;
|
||||
/** 유토곡선 오버레이 최소 높이 — 메인 패널 하한 계산(Profile_Panel)이 함께 쓴다. */
|
||||
export const MASSHAUL_MIN_HEIGHT = OVERLAY_MIN_HEIGHT;
|
||||
const OVERLAY_DEFAULT_HEIGHT = 280;
|
||||
/** 전체 맞춤(토량 분배) 보기의 우측 여백(px) — 마지막 점의 값 글자가 잘리지 않을 만큼만. */
|
||||
const MASS_FIT_PAD_RIGHT = 12;
|
||||
/** 오버레이가 패널 본문을 다 덮지 않게 남기는 상한 비율. */
|
||||
/** 판이 종단 열을 다 차지하지 않게 남기는 상한 비율. */
|
||||
const OVERLAY_MAX_RATIO = 0.8;
|
||||
/** 리사이저가 높이를 담는 CSS 변수 — CSS 기본값(280px)은 OVERLAY_DEFAULT_HEIGHT와 같아야 한다. */
|
||||
const HEIGHT_VAR = "--b05-masshaul-height";
|
||||
@@ -147,24 +144,18 @@ export interface RouteMassHaulDrawParams {
|
||||
}
|
||||
|
||||
export interface RouteMassHaulPanel {
|
||||
/** 종단면 패널 바닥에 붙는 2차 슬라이드 손잡이(펼치기/접기). */
|
||||
/** 판 머리줄의 접기 손잡이(펼치기/접기) — 판 안에 있다. */
|
||||
handle: HTMLElement;
|
||||
/** 오버레이 서브패널 — 호출한 쪽이 `position: relative`인 패널 본문 래퍼에 붙인다. */
|
||||
/** 유토곡선 판 — 호출한 쪽이 종단 열 아래(`b05-route-profile__column` 끝)에 붙인다. */
|
||||
overlay: HTMLElement;
|
||||
isOpen(): boolean;
|
||||
/** 저장된 오버레이 높이(px) — 리사이저 변수(세션)나 기본값. 임시 축소(인라인)는 무시한다.
|
||||
* Panel의 높이 캐스케이드가 호출마다 같은 판정을 내리는 기준(2026-08-06 진동 수정). */
|
||||
desiredHeight(): number;
|
||||
/** 저장 높이를 확정한다(리사이저와 같은 변수+세션 기록) — 메인 패널 비례 연동이
|
||||
* 드래그를 마친 높이를 앞으로의 기준으로 못 박는 데 쓴다(2026-08-06 사용자 확정). */
|
||||
commitHeight(px: number): void;
|
||||
/** 손잡이를 오버레이 위 경계로 다시 맞춘다 — 캐스케이드가 인라인 높이를 바꾼 직후 호출. */
|
||||
syncHandle(): void;
|
||||
setContext(next: RouteMassHaulContext | null): void;
|
||||
/** 종단 가로 스크롤러와 scrollLeft를 양방향 동기화한다(설치 1회). */
|
||||
attachScrollSync(main: HTMLElement): void;
|
||||
/** 오버레이 내용을 다시 그린다. 접혀 있으면 오버레이만 숨기고 끝낸다. */
|
||||
/** 판 내용을 다시 그린다. 접혀 있으면 머리줄만 남기고 끝낸다. */
|
||||
draw(params: RouteMassHaulDrawParams): void;
|
||||
/** 가로 스크롤로 보이는 구간만 바뀜 — 창을 따를 때만 · 한 틀 한 번 · 다시 셈 없음(354-3). */
|
||||
drawWindow(params: RouteMassHaulDrawParams): void;
|
||||
}
|
||||
|
||||
function readVisible(): Set<string> {
|
||||
@@ -173,8 +164,11 @@ function readVisible(): Set<string> {
|
||||
if (!raw) return new Set(DEFAULT_VISIBLE);
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
// 기준 키는 라디오라 항상 하나로 눌러 맞춘다(B06 읽기와 같은 규칙).
|
||||
// 종단 기준은 안 그림(354-2) — 저장된 범례가 종단이면 횡단으로 맞춘다.
|
||||
return Array.isArray(parsed)
|
||||
? normalizeVisibleBasis(new Set(parsed.map(String)))
|
||||
? normalizeVisibleBasis(
|
||||
new Set(parsed.map((key) => (key === "longitudinal" ? "cross" : String(key)))),
|
||||
)
|
||||
: new Set(DEFAULT_VISIBLE);
|
||||
} catch {
|
||||
return new Set(DEFAULT_VISIBLE);
|
||||
@@ -205,8 +199,12 @@ export function createRouteMassHaulPanel(
|
||||
handle.className = "b05-profile__masshaul-handle";
|
||||
handle.append(handleControl.root);
|
||||
handleControl.root.setAttribute("aria-label", "유토곡선 패널");
|
||||
// 머리줄 — 접어도 남아 [창 분할]([attachSubPanelPopouts] 가 맨 앞에 붙임) · 손잡이가 닿는다(354-1).
|
||||
const head = document.createElement("div");
|
||||
head.className = "b05-profile__masshaul-head";
|
||||
head.append(handle);
|
||||
|
||||
// 오버레이 뼈대 — 위 경계 리사이저 + 안내 문구 자리 + 가로 스크롤러 + 범례.
|
||||
// 판 뼈대 — 위 경계 리사이저 + 머리줄 + 안내 문구 자리 + 가로 스크롤러 + 범례.
|
||||
// (요약 막대는 2026-09-06 종단 상단줄로 올라갔다.)
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "b05-profile__masshaul-overlay";
|
||||
@@ -233,8 +231,7 @@ export function createRouteMassHaulPanel(
|
||||
max: () => (overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO,
|
||||
storageKey: OVERLAY_HEIGHT_KEY,
|
||||
onResize: () => {
|
||||
// 손잡이는 경계를 즉시 따라가고(가벼움), 전체 redraw는 프레임당 한 번만.
|
||||
syncHandlePosition();
|
||||
// 전체 redraw는 프레임당 한 번만.
|
||||
if (resizeRedrawPending) return;
|
||||
resizeRedrawPending = true;
|
||||
frameOf(overlay, () => {
|
||||
@@ -249,20 +246,14 @@ export function createRouteMassHaulPanel(
|
||||
pendingChip.className = "b05-profile__masshaul-pending";
|
||||
pendingChip.textContent = "다시 계산 중…";
|
||||
pendingChip.hidden = true;
|
||||
overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip);
|
||||
overlay.append(resizer.root, head, bar, scroll, legendLayer, pendingChip);
|
||||
|
||||
// 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시).
|
||||
// scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로.
|
||||
attachWheelHorizontalScroll(scroll);
|
||||
|
||||
/**
|
||||
* 손잡이를 오버레이 **위 경계**에 태운다(2026-08-04 사용자 지시 — 펼치면 같이 올라와
|
||||
* 테이블 영역을 침범해도 된다). 접혀 있으면 패널 바닥으로 돌아온다 — 유토곡선은 서브패널
|
||||
* 순서상 맨 아래라 테이블이 열려 있어도 버튼은 바닥에 남는다(2026-08-05 사용자 확정).
|
||||
*/
|
||||
function syncHandlePosition(): void {
|
||||
handle.style.bottom = open ? `${overlay.offsetHeight}px` : "0px";
|
||||
}
|
||||
/** 곡선을 그리는가 — 펼침이거나 새 창으로 뗌(뗀 창엔 접기가 없음 · 354-1). */
|
||||
const shown = (): boolean => open || overlay.classList.contains("is-popout");
|
||||
|
||||
function applyOpen(next: boolean): void {
|
||||
open = next;
|
||||
@@ -271,14 +262,14 @@ export function createRouteMassHaulPanel(
|
||||
// 툴팁은 setOpen이 일반 문구로 덮으므로 매번 유토곡선용으로 다시 밝힌다.
|
||||
handleControl.root.title = next ? "유토곡선 접기" : "유토곡선 펼치기";
|
||||
handle.classList.toggle("is-open", next);
|
||||
overlay.hidden = !next;
|
||||
syncHandlePosition();
|
||||
// 접으면 머리줄만 남는다(배수유역도처럼 손잡이는 늘 닿음)
|
||||
overlay.classList.toggle("is-collapsed", !next);
|
||||
onChanged();
|
||||
}
|
||||
handleControl.setOpen(open);
|
||||
handleControl.root.title = open ? "유토곡선 접기" : "유토곡선 펼치기";
|
||||
handle.classList.toggle("is-open", open);
|
||||
overlay.hidden = !open;
|
||||
overlay.classList.toggle("is-collapsed", !open);
|
||||
handleControl.root.addEventListener("click", () => applyOpen(!open));
|
||||
|
||||
function toggleSeries(key: string): void {
|
||||
@@ -288,32 +279,29 @@ export function createRouteMassHaulPanel(
|
||||
onChanged();
|
||||
}
|
||||
|
||||
// 종단 스크롤러와의 양방향 동기화 — 재진입 루프는 플래그로 끊는다.
|
||||
// 종단 스크롤러와의 양방향 동기화 — 메아리는 공용 도우미가 걸러 냄(354-3).
|
||||
// 토량 분배(전체 맞춤) 중에는 끊는다 — 유토곡선이 0 으로 붙으며 낸 scroll 이 종단 그래프 ·
|
||||
// 표까지 0 으로 되돌렸다(PLAN 79-1). 유토곡선만 스크롤이 없는 상태.
|
||||
let syncing = false;
|
||||
let fitted = false;
|
||||
function mirror(from: HTMLElement, to: HTMLElement): void {
|
||||
from.addEventListener("scroll", () => {
|
||||
if (syncing || fitted) return;
|
||||
syncing = true;
|
||||
to.scrollLeft = from.scrollLeft;
|
||||
syncing = false;
|
||||
});
|
||||
}
|
||||
let syncedMain: HTMLElement | null = null;
|
||||
let putScroll: ((to: HTMLElement, left: number) => void) | null = null;
|
||||
/** 종단과 맞물려 그린 곡선이 놓인 문서 — 처음 · 분배 맞춤 뒤 · 창 옮김 뒤면 종단 자리로 맞춘다. */
|
||||
let syncedIn: Document | null = null;
|
||||
function attachScrollSync(main: HTMLElement): void {
|
||||
if (syncedMain === main) return;
|
||||
syncedMain = main;
|
||||
mirror(main, scroll);
|
||||
mirror(scroll, main);
|
||||
putScroll = linkScrollLeft(main, scroll, () => fitted);
|
||||
}
|
||||
|
||||
/** 곡선을 한 번이라도 그렸는지 — 재계산 대기 중 화면을 비우지 않기 위한 표시. */
|
||||
let drawnOnce = false;
|
||||
/** 마지막으로 낸 계열 — 가로 스크롤(보이는 구간만 바뀜)은 다시 셈 없이 이걸로 그린다(354-3). */
|
||||
let lastSeries: MassHaulSeries[] | null = null;
|
||||
|
||||
function note(message: string): void {
|
||||
drawnOnce = false;
|
||||
lastSeries = null;
|
||||
syncedIn = null;
|
||||
pendingChip.hidden = true;
|
||||
bar.replaceChildren();
|
||||
scroll.replaceChildren();
|
||||
@@ -360,18 +348,33 @@ export function createRouteMassHaulPanel(
|
||||
);
|
||||
}
|
||||
|
||||
/** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. */
|
||||
function draw(params: RouteMassHaulDrawParams): void {
|
||||
pipelineWork("유토곡선", () => drawNow(params));
|
||||
/** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. `reuse` = 마지막 계열로(다시 셈 없음). */
|
||||
function draw(params: RouteMassHaulDrawParams, reuse = false): void {
|
||||
pipelineWork("유토곡선", () => drawNow(params, reuse));
|
||||
}
|
||||
|
||||
function drawNow(params: RouteMassHaulDrawParams): void {
|
||||
/** 가로 스크롤로 보이는 구간만 바뀜(354-3) — 곡선이 창을 따를 때만(열림 · 분배 맞춤 아님 · 붙음) ·
|
||||
* 한 틀 한 번 · 다시 셈 없이 세로 창만 다시 그림. 접힘 · 분배 · 새 창은 스크롤마다 할 일 없음. */
|
||||
let windowDirty = false;
|
||||
function drawWindow(params: RouteMassHaulDrawParams): void {
|
||||
if (!open || fitted || overlay.classList.contains("is-popout") || !lastSeries) return;
|
||||
lastParams = params;
|
||||
overlay.hidden = !open;
|
||||
syncHandlePosition();
|
||||
// 미끄러짐 틀이 이미 잡혀 있으면 그 틀이 이 값으로 그림
|
||||
if (windowDirty || windowState.frame) return;
|
||||
windowDirty = true;
|
||||
frameOf(overlay, () => {
|
||||
if (windowDirty && lastParams) draw(lastParams, true);
|
||||
});
|
||||
}
|
||||
|
||||
function drawNow(params: RouteMassHaulDrawParams, reuse: boolean): void {
|
||||
lastParams = params;
|
||||
windowDirty = false;
|
||||
overlay.classList.toggle("is-collapsed", !open);
|
||||
// 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다.
|
||||
// 펼친 경우에는 아래에서 두 기준을 내므로 그 결과로 총괄값을 낸다(계산 한 번).
|
||||
if (!open) {
|
||||
// 펼친 경우에는 아래에서 낸 계열로 총괄값을 낸다(계산 한 번).
|
||||
if (!shown()) {
|
||||
syncedIn = null;
|
||||
reportSummary(params);
|
||||
return;
|
||||
}
|
||||
@@ -400,23 +403,29 @@ export function createRouteMassHaulPanel(
|
||||
.map((chainage) => `${chainage.toFixed(2)} m`)
|
||||
.join(" · ")}${stale.length > 5 ? " …" : ""}`
|
||||
: "다시 계산 중…";
|
||||
// B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다.
|
||||
// B06과 같은 계산 — 횡단 기준(정식)만 낸다. 종단 기준은 안 셈 · 안 그림 · 범례 단추 없음
|
||||
// (354-2 · 계산 코드는 공용에 남김).
|
||||
// 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는
|
||||
// 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림).
|
||||
const series: MassHaulSeries[] = computeMassHaulSeries(
|
||||
params.longitudinal,
|
||||
params.crossSections,
|
||||
context.conversion,
|
||||
context.naturalSpoilMinSlope,
|
||||
);
|
||||
// 스크롤로 창만 바뀐 그리기(`reuse`)는 직전 계열 그대로 — 같은 입력의 두 번째 적분이었다(354-3).
|
||||
const series: MassHaulSeries[] =
|
||||
reuse && lastSeries
|
||||
? lastSeries
|
||||
: computeMassHaulSeries(
|
||||
params.longitudinal,
|
||||
params.crossSections,
|
||||
context.conversion,
|
||||
context.naturalSpoilMinSlope,
|
||||
["cross"],
|
||||
);
|
||||
// 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다.
|
||||
emitSummary(series);
|
||||
if (series !== lastSeries) emitSummary(series);
|
||||
lastSeries = series;
|
||||
if (!series.length) {
|
||||
note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
bar.replaceChildren();
|
||||
legendLayer.replaceChildren();
|
||||
const visible = readVisible();
|
||||
// 토량 분배는 켜 둔 첫 곡선(정식 우선)에만 얹는다 — B06과 같은 규칙.
|
||||
const banded = series.find((entry) => visible.has(entry.key));
|
||||
@@ -486,24 +495,30 @@ export function createRouteMassHaulPanel(
|
||||
wrap.append(chart);
|
||||
// 앵커는 첫 자식 — SVG 뒤면 흐름 위치가 밀려 스크롤 시 축이 안 보인다(B06과 같은 규칙).
|
||||
if (massAxis) wrap.prepend(buildStickyYAxis(massAxis, chartHeight));
|
||||
// 이미 종단과 맞물려 그린 곡선이면 제 자리 그대로 — 종단 값으로 되쓰면 그 사이 유토 쪽 휠이
|
||||
// 더 간 만큼 뒤로 튐(354-3). 처음 · 분배 맞춤 뒤 · 창 옮김 뒤만 종단 자리에서 시작한다.
|
||||
const keepLeft = !fitAll && syncedIn === scroll.ownerDocument ? scroll.scrollLeft : null;
|
||||
scroll.replaceChildren(wrap);
|
||||
// 종단 스크롤러가 이미 옆으로 가 있으면 새로 그린 곡선도 같은 자리에서 시작한다.
|
||||
// 전체 맞춤일 때는 스크롤할 것이 없으므로 왼쪽 끝에 둔다.
|
||||
if (fitAll) scroll.scrollLeft = 0;
|
||||
else if (syncedMain) scroll.scrollLeft = syncedMain.scrollLeft;
|
||||
else if (keepLeft !== null) scroll.scrollLeft = keepLeft;
|
||||
else if (syncedMain) putScroll?.(scroll, syncedMain.scrollLeft);
|
||||
syncedIn = fitAll ? null : scroll.ownerDocument;
|
||||
|
||||
legendLayer.append(
|
||||
createMassHaulLegend(series, visible, toggleSeries, () => {
|
||||
resetBalloonOffsets();
|
||||
onChanged();
|
||||
}),
|
||||
);
|
||||
// 범례는 계열 · 켬이 바뀔 때만 — 스크롤 그리기는 그대로 둔다.
|
||||
if (!reuse || !legendLayer.firstChild)
|
||||
legendLayer.replaceChildren(
|
||||
createMassHaulLegend(series, visible, toggleSeries, () => {
|
||||
resetBalloonOffsets();
|
||||
onChanged();
|
||||
}),
|
||||
);
|
||||
drawnOnce = true;
|
||||
// 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다.
|
||||
scheduleMassHaulSettle(
|
||||
windowState,
|
||||
() => {
|
||||
if (lastParams) draw(lastParams);
|
||||
if (lastParams) draw(lastParams, true);
|
||||
},
|
||||
overlay,
|
||||
);
|
||||
@@ -512,22 +527,12 @@ export function createRouteMassHaulPanel(
|
||||
return {
|
||||
handle,
|
||||
overlay,
|
||||
isOpen: () => open,
|
||||
desiredHeight() {
|
||||
const raw = parseFloat(overlay.style.getPropertyValue(HEIGHT_VAR));
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : OVERLAY_DEFAULT_HEIGHT;
|
||||
},
|
||||
commitHeight(px) {
|
||||
const next = Math.round(px);
|
||||
overlay.style.setProperty(HEIGHT_VAR, `${next}px`);
|
||||
writeStateRaw("masshaul-height", String(next));
|
||||
syncHandlePosition();
|
||||
},
|
||||
syncHandle: () => syncHandlePosition(),
|
||||
isOpen: shown,
|
||||
setContext(next) {
|
||||
context = next;
|
||||
},
|
||||
attachScrollSync,
|
||||
draw,
|
||||
draw: (params) => draw(params),
|
||||
drawWindow,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,7 +64,10 @@ export interface ProfileRenderContext {
|
||||
* 돌려주는 값 = 다시 셈을 기다리는 중(false 면 남은 낡음 — 곡선은 그리고 그 측점만 표시 · 342-1). */
|
||||
onStaleDesigns?: (key: string) => boolean;
|
||||
callbacks: RouteProfilePanelCallbacks | undefined;
|
||||
massHaul: { draw: (params: RouteMassHaulDrawParams) => void };
|
||||
massHaul: {
|
||||
draw: (params: RouteMassHaulDrawParams) => void;
|
||||
drawWindow: (params: RouteMassHaulDrawParams) => void;
|
||||
};
|
||||
tableOverlay: {
|
||||
isOpen: () => boolean;
|
||||
contentHeight: () => number;
|
||||
@@ -452,9 +455,9 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
// 유토곡선도 **같은 창**을 본다(2026-09-04 사용자 확정) — 스크롤할 때마다 곡선만 다시
|
||||
// 그린다. 종단처럼 변환으로 옮기지 않는 이유: 곡선 위에 앉는 말풍선·EP 표·측점 점이
|
||||
// 세로로 늘어나면 안 되는 것들이라, 그것만 따로 옮기는 값이 곡선 자체를 다시 그리는
|
||||
// 값보다 크다(요소 수가 종단의 1/3).
|
||||
// 값보다 크다(요소 수가 종단의 1/3). 열림 · 분배 맞춤 아닐 때만 · 한 틀 한 번 · 다시 셈 없음(354-3).
|
||||
massHaulUpdater = (fromM, toM) => {
|
||||
massHaul.draw({
|
||||
massHaul.drawWindow({
|
||||
...massHaulParams,
|
||||
axis: { ...massHaulParams.axis, viewRange: { fromM, toM } },
|
||||
});
|
||||
|
||||
@@ -4,17 +4,15 @@
|
||||
* 오버레이 서브패널로 감싼다(2026-08-05 사용자 지시).
|
||||
*
|
||||
* - 접힘/펼침 표준 삼각형 손잡이 + 위 경계 리사이저 + 세션 보존.
|
||||
* - 유토곡선 오버레이가 함께 열리면 그 **위**에 쌓인다 — 위→아래 순서가
|
||||
* 종단도 → 테이블 → 유토곡선이 되도록 setBottomOffset으로 바닥 간격을 받는다.
|
||||
* - 접혔을 때 손잡이는 유토곡선 손잡이와 가로로 나란히(일렬) 놓인다(CSS에서
|
||||
* 가로 오프셋). 테이블 내용물은 Profile_Panel의 draw()가 만들어 setTable로 넣는다.
|
||||
* - 종단 칸 바닥에 붙는다 — 유토곡선은 그 아래 자기 판이라 쌓지 않는다(354-1).
|
||||
* - 테이블 내용물은 Profile_Panel의 draw()가 만들어 setTable로 넣는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
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 { attachWheelHorizontalScroll, linkScrollLeft } from "./B05_Profile_UI_Profile_Wheel";
|
||||
import { frameOf } from "../common_util/common_util_view_window";
|
||||
|
||||
/* 펼침·높이는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
|
||||
@@ -54,8 +52,6 @@ export interface RouteProfileTableOverlay {
|
||||
setTable: (table: HTMLElement | null) => void;
|
||||
/** 종단 그래프 스크롤러와 가로 스크롤 양방향 동기화. */
|
||||
attachScrollSync: (main: HTMLElement) => void;
|
||||
/** 유토곡선 오버레이가 차지한 바닥 높이(px). 그 위로 쌓인다. */
|
||||
setBottomOffset: (px: number) => void;
|
||||
}
|
||||
|
||||
export function createProfileTableOverlay(onChanged: () => void): RouteProfileTableOverlay {
|
||||
@@ -73,7 +69,6 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
|
||||
// 상태 선언은 리사이저 생성보다 먼저 — 세션 높이 복원이 생성 중 onResize를 부른다
|
||||
// (유토곡선 TDZ 크래시와 같은 함정, 2026-08-04 확인).
|
||||
let open = readStateRaw("table-open") !== "false"; // 기본 펼침(기존 테이블 상시 표시 유지)
|
||||
let bottomOffset = 0;
|
||||
let resizeRedrawPending = false;
|
||||
const resizer = createPanelResizer({
|
||||
axis: "vertical",
|
||||
@@ -102,11 +97,9 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
|
||||
// 유토곡선 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(종단 스크롤도 함께 움직인다).
|
||||
attachWheelHorizontalScroll(scroll);
|
||||
|
||||
/** 손잡이는 오버레이 위 경계를, 접히면 열린 유토곡선의 위 경계(bottomOffset)를 따라간다
|
||||
* — 접힌 버튼이 펼쳐진 패널 수평선에 놓인다(2026-08-05 사용자 지시). */
|
||||
/** 손잡이는 오버레이 위 경계를, 접히면 종단 칸 바닥을 따라간다(2026-08-05 사용자 지시). */
|
||||
function syncHandlePosition(): void {
|
||||
handle.style.bottom = open ? `${bottomOffset + overlay.offsetHeight}px` : `${bottomOffset}px`;
|
||||
overlay.style.bottom = `${bottomOffset}px`;
|
||||
handle.style.bottom = open ? `${overlay.offsetHeight}px` : "0px";
|
||||
}
|
||||
|
||||
function applyOpen(next: boolean): void {
|
||||
@@ -125,16 +118,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
|
||||
overlay.hidden = !open;
|
||||
handleControl.root.addEventListener("click", () => applyOpen(!open));
|
||||
|
||||
// 종단 스크롤러와의 양방향 동기화 — 재진입 루프는 플래그로 끊는다(유토곡선과 동일).
|
||||
let syncing = false;
|
||||
function mirror(from: HTMLElement, to: HTMLElement): void {
|
||||
from.addEventListener("scroll", () => {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
to.scrollLeft = from.scrollLeft;
|
||||
syncing = false;
|
||||
});
|
||||
}
|
||||
// 종단 스크롤러와의 양방향 동기화 — 메아리는 공용 도우미가 걸러 냄(유토곡선과 같음 · 354-3).
|
||||
let syncedMain: HTMLElement | null = null;
|
||||
|
||||
return {
|
||||
@@ -161,12 +145,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
|
||||
attachScrollSync(main) {
|
||||
if (syncedMain === main) return;
|
||||
syncedMain = main;
|
||||
mirror(main, scroll);
|
||||
mirror(scroll, main);
|
||||
},
|
||||
setBottomOffset(px) {
|
||||
bottomOffset = px;
|
||||
syncHandlePosition();
|
||||
linkScrollLeft(main, scroll);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,6 +39,35 @@ const BASE_FRAME_MS = 1000 / 60;
|
||||
* 쓰면 한 번에 목표까지 튄다. */
|
||||
const MAX_FRAME_MS = 64;
|
||||
|
||||
/**
|
||||
* 두 스크롤러의 가로 자리를 서로 따라가게 잇는다 — 종단 그래프 ↔ 유토곡선 · 표(354-3).
|
||||
* 써 넣은 값으로 **다음 틀에** 오는 scroll(메아리)은 무시한다. 동기 플래그로 막던 예전 길은
|
||||
* 메아리가 한 틀 전 값을 되써 앞서 간 쪽을 뒤로 튀게 했고, 그 튐이 휠 이동을 접어 끊겼다.
|
||||
* `paused` 가 참이면 안 따라감. 돌려주는 `put` = 따라가는 쪽에 손으로 써 넣을 때(메아리 표시).
|
||||
*/
|
||||
export function linkScrollLeft(
|
||||
a: HTMLElement,
|
||||
b: HTMLElement,
|
||||
paused: () => boolean = () => false,
|
||||
): (to: HTMLElement, left: number) => void {
|
||||
const echo = new Map<HTMLElement, number>();
|
||||
const put = (to: HTMLElement, left: number): void => {
|
||||
const before = to.scrollLeft;
|
||||
to.scrollLeft = left;
|
||||
if (to.scrollLeft !== before) echo.set(to, to.scrollLeft);
|
||||
};
|
||||
const follow = (from: HTMLElement, to: HTMLElement): void =>
|
||||
from.addEventListener("scroll", () => {
|
||||
const wrote = echo.get(from);
|
||||
echo.delete(from);
|
||||
if (wrote !== undefined && Math.abs(from.scrollLeft - wrote) < 1) return;
|
||||
if (!paused() && to.scrollLeft !== from.scrollLeft) put(to, from.scrollLeft);
|
||||
});
|
||||
follow(a, b);
|
||||
follow(b, a);
|
||||
return put;
|
||||
}
|
||||
|
||||
export function attachWheelHorizontalScroll(scroller: HTMLElement): void {
|
||||
let target = -1;
|
||||
/** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */
|
||||
|
||||
@@ -28,12 +28,14 @@ import {
|
||||
loadRouteEditBackground,
|
||||
type RouteEditContours,
|
||||
} from "./B05_Profile_UI_RouteEdit_Contour";
|
||||
import { createContourLock } from "./B05_Profile_UI_RouteEdit_ContourLock";
|
||||
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
|
||||
import {
|
||||
analysisTerrain,
|
||||
fetchRoutePlan,
|
||||
fetchTerrainSources,
|
||||
setAnalysisTerrain,
|
||||
setTerrainContext,
|
||||
type AnalysisTerrain,
|
||||
} from "./B05_Profile_Api_Replan";
|
||||
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
|
||||
@@ -89,6 +91,7 @@ import {
|
||||
} from "./B05_Profile_UI_RouteEdit_History";
|
||||
import type { DesignPageHost } from "../A00_Common/design/design_page";
|
||||
import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft";
|
||||
import { bindRouteShift } from "./B05_Profile_UI_RouteEdit_Shift";
|
||||
import "./B05_Profile_UI_Style_RouteEdit.css";
|
||||
|
||||
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
|
||||
@@ -184,11 +187,17 @@ export async function mountRouteEditor(
|
||||
let designTerrain: AnalysisTerrain = "las";
|
||||
/** [확인]에 실을 분석 지형 — 고르기가 안 보이면 null(설계 지형 그대로). */
|
||||
const pickedTerrain = (): AnalysisTerrain | null =>
|
||||
terrainSelect.closest("label")!.hidden ? null : analysisTerrain(projectId, designTerrain);
|
||||
terrainSelect.closest("label")!.hidden ? null : analysisTerrain(projectId);
|
||||
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
|
||||
let otherSheets: PreparedLayer[] = [];
|
||||
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
|
||||
let pickedContour = -1;
|
||||
/** 등고선 자물쇠(353-2) — 잠긴 동안 빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜. */
|
||||
const contourLock = createContourLock(
|
||||
canvas,
|
||||
() => contours && { layer: contours.layer, view, stepM: contourStepM(), rotation },
|
||||
(index) => ((pickedContour = index), draw()),
|
||||
);
|
||||
/** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */
|
||||
const crossPreview = createCrossPreview({
|
||||
projectId,
|
||||
@@ -284,13 +293,6 @@ export async function mountRouteEditor(
|
||||
planMinRadiusM: () => minRadiusM,
|
||||
apply: (snapshot, message) => restoreSnapshot(snapshot, message, true),
|
||||
});
|
||||
// [저장 전으로] — 마지막 저장(페이지를 세운 때) 노선 · 그 걸음의 「현재 추천」으로. 되돌리기로 다시 살림.
|
||||
overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", () => {
|
||||
const snapshot = history?.reset();
|
||||
if (!snapshot) return showToast("반영 뒤 고친 노선이 없습니다.", "info");
|
||||
initial.restoreTag(snapshot.rec);
|
||||
restoreSnapshot(snapshot, "반영 전 노선으로 되돌렸습니다.", false);
|
||||
});
|
||||
|
||||
function resize(): void {
|
||||
if (closed || !overlay.isConnected) return;
|
||||
@@ -521,7 +523,7 @@ export async function mountRouteEditor(
|
||||
picked = index;
|
||||
},
|
||||
setPickedContour: (index) => {
|
||||
pickedContour = index;
|
||||
if (contourLock.canPick()) pickedContour = index;
|
||||
},
|
||||
toScreen,
|
||||
toMetric,
|
||||
@@ -627,32 +629,33 @@ export async function mountRouteEditor(
|
||||
history = createRouteEditHistory(snapshotNow());
|
||||
history = draftLink.watch(history, options.draft, () => stationSnap);
|
||||
draftLink.restore(options.draft, restoreSnapshot);
|
||||
bindRouteShift(host, projectId, snapshotNow, applyEdit, () => [plannedLine, curveInfo]); // 356-3
|
||||
historyControls.sync();
|
||||
syncCurveBar();
|
||||
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
|
||||
meta = drainage.meta;
|
||||
// 분석 지형 고르기(PLAN 133-1) — 라이다 · 수치지형도 둘 다 있을 때만 보임 · 기본 라이다.
|
||||
// 고른 값이 없으면 지금 설계 지형(133-2) · 못 고르는 프로젝트는 설계 지형 그대로.
|
||||
// 분석 지형 고르기(PLAN 133-1) — 라이다 · 수치지형도 둘 다 있을 때만 보임 · 열 때 안 씀(352-3).
|
||||
// 고른 적 없으면 데이터대로(설계 지형 · 라이다가 있으면 라이다) · 사용자가 바꿀 때만 기억.
|
||||
designTerrain = sources?.design ?? "las";
|
||||
const choosable = !!sources?.sources.includes("las") && !!sources?.sources.includes("sheet");
|
||||
terrainSelect.closest("label")!.hidden = !choosable;
|
||||
setAnalysisTerrain(
|
||||
projectId,
|
||||
choosable ? analysisTerrain(projectId, designTerrain) : designTerrain,
|
||||
);
|
||||
setTerrainContext(projectId, choosable, designTerrain);
|
||||
terrainSelect.value = analysisTerrain(projectId);
|
||||
// 등고선은 따로 고른다(LAS 우선 · 수치지형도를 고르면 도엽). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다.
|
||||
// 확정 모델 등고선은 설계 지형이 라이다일 때만 라이다 것 — 수치지형도 설계면 도엽 선(글도 도엽).
|
||||
const loadBackground = () =>
|
||||
loadRouteEditBackground(projectId, drainage, {
|
||||
surfaceModelId:
|
||||
analysisTerrain(projectId) === "sheet" ? null : (options.surfaceModelId ?? null),
|
||||
analysisTerrain(projectId) === "las" && designTerrain === "las"
|
||||
? (options.surfaceModelId ?? null)
|
||||
: null,
|
||||
intervalM: options.contourIntervalM ?? 1,
|
||||
smooth: options.smooth ?? false,
|
||||
});
|
||||
const useBackground = (background: Awaited<ReturnType<typeof loadBackground>>): void => {
|
||||
otherSheets = background.otherSheets;
|
||||
contours = background.contours;
|
||||
pickedContour = -1;
|
||||
pickedContour = contourLock.reattach(contours?.layer ?? null);
|
||||
// 1m 선이 있는 자료(LAS)일 때만 단추를 연다 — 도엽에는 1m 선이 없다.
|
||||
minorContourButton.disabled = !contours?.minor;
|
||||
minorContourButton.title = contours?.minor
|
||||
@@ -672,6 +675,9 @@ export async function mountRouteEditor(
|
||||
let terrainTicket = 0;
|
||||
terrainSelect.addEventListener("change", () => {
|
||||
setAnalysisTerrain(projectId, terrainSelect.value === "sheet" ? "sheet" : "las");
|
||||
// 계산 묶음에 알림 — 노선이 그대로여도 지형이 설계와 다르면 초안으로 다시 셈(352-3).
|
||||
const dirty = history?.isDirty() || pickedTerrain() !== designTerrain;
|
||||
options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), stationSnap) : null);
|
||||
const mine = ++terrainTicket;
|
||||
void loadBackground().then((background) => {
|
||||
if (closed || mine !== terrainTicket) return;
|
||||
|
||||
@@ -8,13 +8,13 @@ import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint";
|
||||
* `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 생김새만 있고
|
||||
* 동작은 없다 — 배선은 본체와 각 조각(`_Apply`·`_Rotate`·`_History`…)이 한다.
|
||||
*
|
||||
* **배치**(2026-09-12 사용자 지시 ⑩~⑬·⑱) — 아래 정보행을 없애고 지도 위 오버레이로 옮겼다.
|
||||
* 단추는 제목행 오른쪽, 조작 설명은 지도 왼쪽 위 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽
|
||||
* 아래. 메인 창은 왼쪽으로 밀고 오른쪽 세로 칸에 횡단 두 판이 앉는다.
|
||||
* **배치**(352-1) — 지도가 칸 전체 · 머리 줄 · 테두리 없음. 도구는 3D 와 같은 떠 있는 유리 단추
|
||||
* (지도 왼쪽 위 · ☰ 비킴), [?] 조작 설명은 도구줄 아래 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽
|
||||
* 아래. 횡단 두 판은 지도 오른쪽을 덮는 판(아래 종단 판이 그 위).
|
||||
*
|
||||
* 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 ·
|
||||
* 닫기 단추는 걷었다 · [취소] → [저장 전으로] · [확인] · [노선 반영]은 없앰(346-3 — 노선은 페이지 [저장]이
|
||||
* 맨 앞에서 보냄) · 오른쪽 횡단 칸은 접는 칸.
|
||||
* 닫기 단추는 걷었다 · [취소] · [확인] · [노선 반영] · [저장 전으로]는 없앰(346-3 · 352-2 — 노선은 페이지
|
||||
* [저장]이 맨 앞에서 보냄) · 오른쪽 횡단 칸은 접는 칸.
|
||||
* 머리 `tools` 자리에 부른 쪽 단추([3D])가 앉는다. [노선 추천] · 현재 추천 · [초기화]는 좌측
|
||||
* 「노선 추천」 칸으로 옮겼다(345-4 · `_Panel_Recommend`).
|
||||
* ========================================================================== */
|
||||
@@ -57,12 +57,11 @@ export function createRouteEditChrome(
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "b05-routeedit";
|
||||
overlay.innerHTML = `
|
||||
<div class="b05-routeedit__box" aria-label="계획노선 편집">
|
||||
<div class="b05-routeedit__head">
|
||||
<h3>계획노선</h3>
|
||||
<span class="b05-routeedit__actions">
|
||||
<div class="b05-routeedit__canvas-wrap" aria-label="계획노선 편집">
|
||||
<canvas class="b05-routeedit__canvas"></canvas>
|
||||
<div class="b05-route__view-controls b05-routeedit__tools">
|
||||
<span data-slot="tools"></span>
|
||||
<i class="b05-routeedit__divider" aria-hidden="true"></i>
|
||||
<i class="b05-route__view-separator" aria-hidden="true"></i>
|
||||
<label class="b05-routeedit__terrain" hidden
|
||||
title="노선 편집 분석(등고선 · 지반고 · 기울기 · 횡단 미리보기 · 추천)에 쓸 지형 — 확정 지표면 · 종횡단은 안 바뀝니다">분석 지형
|
||||
<select class="ui-select ui-select--sm" data-act="terrain">
|
||||
@@ -70,29 +69,24 @@ export function createRouteEditChrome(
|
||||
<option value="sheet">수치지형도</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="fit"
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="fit"
|
||||
title="등고 영역(등고선 범위 · 노선)을 평면 가운데 알맞게 맞춥니다 (회전은 풂)">맞춤</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="contour-minor"
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="contour-minor"
|
||||
aria-pressed="false" disabled
|
||||
title="1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)">1m 등고선</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="measure"
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="measure"
|
||||
title="노선 위 두 점을 눌러 거리·기울기를 잽니다 (Shift+클릭도 같음)">거리 재기</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="station-snap"
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="station-snap"
|
||||
aria-pressed="false"
|
||||
title="곡선(호) 안 측점 하나를 곡선 중점(MC)으로 옮깁니다 — [저장] 때 저장">측점 스냅</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="footprint"
|
||||
title="도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 파랑)을 평면에 겹칩니다">발자국</button>
|
||||
<i class="b05-routeedit__divider" aria-hidden="true"></i>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="undo"
|
||||
title="되돌리기 (Ctrl+Z)" disabled>↶ 되돌리기</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="redo"
|
||||
title="다시하기 (Ctrl+Y)" disabled>↷ 다시하기</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel"
|
||||
title="마지막 [저장] 뒤 고친 노선을 버리고 그때 노선으로 되돌립니다 — 되돌리기로 다시 살릴 수 있습니다">저장 전으로</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="b05-routeedit__canvas-wrap">
|
||||
<canvas class="b05-routeedit__canvas"></canvas>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="footprint"
|
||||
title="도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 파랑)을 평면에 겹칩니다">절/성토선</button>
|
||||
<i class="b05-route__view-separator" aria-hidden="true"></i>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="undo"
|
||||
title="되돌리기 (Ctrl+Z)" aria-label="되돌리기" disabled>↶</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="redo"
|
||||
title="다시하기 (Ctrl+Y)" aria-label="다시하기" disabled>↷</button>
|
||||
</div>
|
||||
<div class="b05-routeedit__hint">
|
||||
<button type="button" class="ui-btn ui-btn--glass b05-routeedit__hint-toggle"
|
||||
data-act="hint-toggle" aria-expanded="false" title="조작 도움말 펼치기 · 접기">?</button>
|
||||
@@ -123,15 +117,16 @@ export function createRouteEditChrome(
|
||||
<button type="button" class="b05-routeedit__info-toggle" data-act="info-toggle"
|
||||
aria-expanded="true" title="상태 · 범례 글판 접기 · 펴기">▾</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b05-routeedit__busy" hidden><span></span></div>
|
||||
</div>
|
||||
<div class="b05-routeedit__busy" hidden><span></span></div>
|
||||
<aside class="b05-routeedit__side-wrap">
|
||||
<button type="button" class="ui-btn ui-btn--ghost b05-routeedit__side-toggle"
|
||||
<button type="button" class="ui-btn ui-btn--glass b05-routeedit__side-toggle"
|
||||
data-act="side-toggle" aria-expanded="true" title="오른쪽 횡단 미리보기 칸 접기 · 펴기">횡단</button>
|
||||
<div class="b05-routeedit__side"></div>
|
||||
</aside>`;
|
||||
parent.append(overlay);
|
||||
// 부른 쪽 단추([3D])도 같은 유리 단추로 — 지도 위에 떠 있는 도구줄.
|
||||
tools.forEach((tool) => tool.classList.replace("ui-btn--ghost", "ui-btn--glass"));
|
||||
overlay.querySelector('[data-slot="tools"]')!.replaceWith(...tools);
|
||||
|
||||
// 횡단 미리보기 = 접는 칸 — 좁은 화면은 접힌 채 시작(지도가 먼저다).
|
||||
@@ -139,6 +134,7 @@ export function createRouteEditChrome(
|
||||
const sideToggle = overlay.querySelector<HTMLButtonElement>('[data-act="side-toggle"]')!;
|
||||
const setSide = (open: boolean): void => {
|
||||
sideWrap.classList.toggle("is-collapsed", !open);
|
||||
overlay.classList.toggle("is-side-open", open); // 회전 단추 · 잰 값 창이 판을 비킴
|
||||
sideToggle.setAttribute("aria-expanded", String(open));
|
||||
};
|
||||
setSide(window.innerWidth >= 1500);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_ContourLock.ts
|
||||
* 평면 등고선 자물쇠(353-2) — 등고선 위를 지나거나 고른 가닥 위에 뜨는 작은 단추.
|
||||
*
|
||||
* 누르면 그 높이를 고정(빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜) · 다시 누르면 풂.
|
||||
* 높이값으로 쥐어 지형을 바꿔도 같은 높이를 다시 짚음 · 페이지 동안만(저장 안 함).
|
||||
* 규칙은 `logic/B05_Profile_ContourLock` · 호버 판정은 집기와 같은 `hitPreparedLayer`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pickPx } from "../A00_Common/touch_bridge";
|
||||
import {
|
||||
hitPreparedLayer,
|
||||
type PreparedLayer,
|
||||
type ViewState,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import {
|
||||
canPickContour,
|
||||
featureOfLevel,
|
||||
lockButtonState,
|
||||
toggleLock,
|
||||
} from "./logic/B05_Profile_ContourLock";
|
||||
import "./B05_Profile_UI_Style_RouteEdit_ContourLock.css";
|
||||
|
||||
/** 집기(`_Pointer` CONTOUR_HIT_PX)와 같은 거리(px). */
|
||||
const HOVER_HIT_PX = 6;
|
||||
/** 커서가 선을 떠난 뒤 단추가 남는 시간(ms) — 단추로 옮겨 갈 틈. */
|
||||
const HIDE_DELAY_MS = 700;
|
||||
/** 단추는 커서 오른쪽 위로 이만큼(px) — 선과 커서를 안 가림. */
|
||||
const OFFSET_PX = 12;
|
||||
|
||||
const LOCK_SVG = `<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
|
||||
<rect x="3" y="7" width="10" height="7" rx="1.5" fill="currentColor"/>
|
||||
<path class="b05-contourlock__shackle" d="M5 7V5a3 3 0 0 1 6 0v2" fill="none"
|
||||
stroke="currentColor" stroke-width="1.6"/></svg>`;
|
||||
|
||||
/** 호버 판정에 쓰는 지금 화면 — 등고선이 없으면 null. */
|
||||
export interface ContourLockScene {
|
||||
layer: PreparedLayer;
|
||||
view: ViewState;
|
||||
/** 지금 화면 등고선 간격 — 그리지 않은 줄은 호버도 안 됨. */
|
||||
stepM: number;
|
||||
rotation: { unrotate: (px: number, py: number) => [number, number] };
|
||||
}
|
||||
|
||||
export interface ContourLock {
|
||||
/** 고른 가닥을 바꿔도 되는지(잠긴 동안 false). */
|
||||
canPick: () => boolean;
|
||||
/** 지형을 바꿔 새 가닥들이 왔다 — 잠근 높이의 가닥 번호(없으면 -1). */
|
||||
reattach: (layer: PreparedLayer | null) => number;
|
||||
}
|
||||
|
||||
/** 자물쇠 단추를 캔버스 칸 위에 세움 · `pick` = 잠글 때 그 가닥을 고른 것으로(다시 그리기까지). */
|
||||
export function createContourLock(
|
||||
canvas: HTMLCanvasElement,
|
||||
scene: () => ContourLockScene | null,
|
||||
pick: (index: number) => void,
|
||||
): ContourLock {
|
||||
const wrap = canvas.parentElement ?? document.body;
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "ui-btn ui-btn--glass b05-contourlock";
|
||||
button.dataset.act = "contour-lock";
|
||||
button.hidden = true;
|
||||
button.innerHTML = LOCK_SVG;
|
||||
wrap.append(button);
|
||||
|
||||
let lockedLevel: number | null = null;
|
||||
/** 단추가 가리키는 가닥 — 누르면 이 가닥을 잠금. */
|
||||
let target = -1;
|
||||
let hideTimer = 0;
|
||||
let frame = 0;
|
||||
let last: PointerEvent | null = null;
|
||||
|
||||
const levelOf = (index: number): number | null =>
|
||||
index >= 0 ? (scene()?.layer.features[index]?.labelValue ?? null) : null;
|
||||
|
||||
const paint = (): void => {
|
||||
const pressed = lockedLevel !== null;
|
||||
button.setAttribute("aria-pressed", String(pressed));
|
||||
const label = pressed
|
||||
? `등고선 ${lockedLevel}m 고정 풀기 — 풀면 다른 등고선을 고를 수 있음`
|
||||
: `등고선 ${levelOf(target) ?? ""}m 고정 — 빈 곳 · 다른 등고선을 눌러도 안 바뀜`;
|
||||
button.setAttribute("aria-label", label);
|
||||
button.title = label;
|
||||
};
|
||||
|
||||
const hideLater = (): void => {
|
||||
if (hideTimer) return;
|
||||
hideTimer = window.setTimeout(() => {
|
||||
hideTimer = 0;
|
||||
button.hidden = true;
|
||||
}, HIDE_DELAY_MS);
|
||||
};
|
||||
const keep = (): void => {
|
||||
window.clearTimeout(hideTimer);
|
||||
hideTimer = 0;
|
||||
};
|
||||
|
||||
const hover = (event: PointerEvent): void => {
|
||||
const now = scene();
|
||||
// 단추를 누른 채(노드 · 손잡이 끌기 · 화면 이동) 지나가면 안 봄
|
||||
if (!now || event.buttons !== 0) return hideLater();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const [px, py] = now.rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
|
||||
const hitPx = pickPx(HOVER_HIT_PX);
|
||||
const index = hitPreparedLayer(now.layer, now.view, px, py, hitPx, now.stepM);
|
||||
const state = lockButtonState(levelOf(index), lockedLevel);
|
||||
if (!state.visible) return hideLater();
|
||||
keep();
|
||||
target = index;
|
||||
const origin = wrap.getBoundingClientRect();
|
||||
button.style.left = `${Math.round(event.clientX - origin.left + OFFSET_PX)}px`;
|
||||
button.style.top = `${Math.round(event.clientY - origin.top - OFFSET_PX)}px`;
|
||||
button.hidden = false;
|
||||
paint();
|
||||
};
|
||||
|
||||
// 한 프레임에 한 번만 잼 — 도엽 등고선은 수천 가닥.
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
last = event;
|
||||
if (frame) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
if (last) hover(last);
|
||||
});
|
||||
});
|
||||
// 고른 직후(누름을 뗀 자리)에도 띄움 — 터치는 호버가 없음.
|
||||
canvas.addEventListener("pointerup", hover);
|
||||
canvas.addEventListener("pointerleave", hideLater);
|
||||
button.addEventListener("pointerenter", keep);
|
||||
button.addEventListener("pointerleave", hideLater);
|
||||
button.addEventListener("pointerdown", (event) => event.stopPropagation());
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
lockedLevel = toggleLock(lockedLevel, levelOf(target));
|
||||
if (lockedLevel !== null) pick(target);
|
||||
paint();
|
||||
});
|
||||
|
||||
return {
|
||||
canPick: () => canPickContour(lockedLevel),
|
||||
reattach: (layer) => {
|
||||
target = -1;
|
||||
button.hidden = true;
|
||||
return featureOfLevel(
|
||||
layer?.features.map((feature) => feature.labelValue) ?? [],
|
||||
lockedLevel,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Draft.ts
|
||||
* 노선 편집기 ↔ 계산 묶음 다리(PLAN 346-1) — 노선을 한 번 고칠 때마다(놓기 · 더함 · 지움 · 곡선 ·
|
||||
* 되돌리기 · 다시하기 · [저장 전으로] · 추천 [적용]) 노선 초안을 넘김. 끄는 동안은 넘기지 않음
|
||||
* 되돌리기 · 다시하기 · 추천 [적용]) 노선 초안을 넘김. 끄는 동안은 넘기지 않음
|
||||
* (사진첩에 쌓이는 때 = 손을 놓은 때).
|
||||
*
|
||||
* 편집기는 사진첩(`_History`)을 `watch` 로 감싸기만 함 — 고친 줄 하나. 저장 안 한 초안은
|
||||
* 다리가 캐시 칸(`route-draft`)에 두고, 편집기가 자료를 읽은 뒤 `restore` 로 한 번 되살림
|
||||
* (새로고침 · 보기 전환에도 남음). 계산 · 가게는 `A00_Common/design/design_pipeline_page.ts` 몫.
|
||||
*
|
||||
* 되돌리기 줄(346-4) — 페이지 사진첩(`_Profile_History`)이 있으면 쌓기 · [저장 전으로] 뒤 사진첩에 한 걸음 ·
|
||||
* 되돌리기 줄(346-4) — 페이지 사진첩(`_Profile_History`)이 있으면 쌓기 ·
|
||||
* [되돌리기] · [다시하기]는 사진첩이 함(평면 · 종단 · 횡단 · 관 · 구조물 한 줄) · 사진첩이 되쓴 노선
|
||||
* 초안은 `fromPage` 로 편집기 새 걸음이 됨. 사진첩이 없으면 편집기 사진 그대로.
|
||||
* ========================================================================== */
|
||||
@@ -99,11 +99,6 @@ export function watch(
|
||||
album.redo();
|
||||
return null;
|
||||
},
|
||||
reset: () => {
|
||||
const snapshot = tell(history.reset());
|
||||
if (snapshot) pageHistory()?.record();
|
||||
return snapshot;
|
||||
},
|
||||
canUndo: () => pageHistory()?.canUndo() ?? history.canUndo(),
|
||||
canRedo: () => pageHistory()?.canRedo() ?? history.canRedo(),
|
||||
fromPage,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*
|
||||
* 되돌리기 줄은 페이지 사진첩 하나(`_Profile_History` · 346-4) — 계산 묶음 다리가 있으면(`_Draft.watch`)
|
||||
* [되돌리기] · [다시하기] · Ctrl+Z · Y 는 사진첩이 받고, 여기 사진은 고정 · 추천 꼬리표 · 고른 점을
|
||||
* 되살리는 기록(`match`)과 [저장 전으로](`reset`) · 고친 것 있음(`isDirty`)에만 씀.
|
||||
* 되살리는 기록(`match`) · 고친 것 있음(`isDirty`)에만 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import { onPageHistory } from "./B05_Profile_UI_Profile_History";
|
||||
@@ -44,8 +44,6 @@ export interface RouteEditHistory {
|
||||
undo: () => RouteEditSnapshot | null;
|
||||
/** 한 걸음 앞으로. 없으면 null. */
|
||||
redo: () => RouteEditSnapshot | null;
|
||||
/** 창을 연 상태로. 이미 그 상태면 null. */
|
||||
reset: () => RouteEditSnapshot | null;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
/** 창을 연 뒤로 고친 것이 있나 — [초기화]를 켤지 정한다. */
|
||||
@@ -115,15 +113,6 @@ export function createRouteEditHistory(initial: RouteEditSnapshot): RouteEditHis
|
||||
at += 1;
|
||||
return clone(steps[at]);
|
||||
},
|
||||
reset() {
|
||||
// 이미 연 상태 그대로면 할 일이 없다 — 눌러도 걸음만 늘어난다.
|
||||
if (sameRoute(steps[at], steps[0])) return null;
|
||||
// 초기화도 **되돌릴 수 있어야** 한다 — 첫 사진을 새 걸음으로 쌓는다.
|
||||
steps.length = at + 1;
|
||||
steps.push(clone(steps[0]));
|
||||
at = steps.length - 1;
|
||||
return clone(steps[at]);
|
||||
},
|
||||
canUndo: () => at > 0,
|
||||
canRedo: () => at < steps.length - 1,
|
||||
isDirty: () => !sameRoute(steps[at], steps[0]),
|
||||
|
||||
@@ -197,7 +197,7 @@ export function createCurveLabel(
|
||||
lockApex.dataset.act = "lock-apex";
|
||||
root.querySelector('[data-slot="lock-apex"]')!.replaceWith(lockApex);
|
||||
|
||||
/** 끝점에서는 숨길 곡선 칸 — 곡선 지우기 단추 · 반지름 줄 · 곡선 길이 줄 · 내각 글. */
|
||||
/** 끝점에서는 숨길 곡선 칸 — 곡선 삭제 단추 · 반지름 줄 · 곡선 길이 줄 · 내각 글. */
|
||||
const curveOnly = [toggle, radiusField.wrapper, arcField.wrapper, info];
|
||||
|
||||
// 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다.
|
||||
@@ -327,7 +327,7 @@ export function createCurveLabel(
|
||||
lockApex.title = end
|
||||
? `${name} 고정 — 켜면 끌어 옮길 수 없습니다. 풀면 끌어 옮깁니다`
|
||||
: "교각점 고정 — 이 꺾임점의 자리를 못 박습니다. 노드도 손잡이도 안 끌리고 반지름·곡선 길이 칸으로만 바뀝니다";
|
||||
toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기";
|
||||
toggle.textContent = state.curveOn ? "곡선 삭제" : "곡선 추가";
|
||||
radius.disabled = !state.curveOn;
|
||||
arc.disabled = !state.curveOn;
|
||||
lockRadius.disabled = !state.curveOn;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Shift.ts
|
||||
* 횡단 중심점 좌우 → 노선 편집(356-3) — B06 카드가 부르는 host(`setRouteShiftHost`)를 편집기가 등록.
|
||||
*
|
||||
* 한 번 = 노드 옮김 · 끼움 한 번(고름은 `logic/B05_Profile_RouteShift`) → 편집기 한 걸음(applyEdit) →
|
||||
* 계산 묶음이 뒤 측점을 조용히 다시 셈 → 새 종단이 오면 그 측점 계획고를 옮기기 전 값으로(바뀐 구간 첫
|
||||
* 계획선 맞춤에 휩쓸리지 않게 · 계획고 편집). 둘을 페이지 되돌리기 한 걸음으로(사진첩 pause · resume).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline";
|
||||
import { pagePipeline, pipelineGrade } from "../A00_Common/design/design_pipeline_page";
|
||||
import { setRouteShiftHost } from "../B06_Section/B06_Section_UI_Cross_Center";
|
||||
import { pageHistory } from "./B05_Profile_UI_Profile_History";
|
||||
import type { RouteEditHost } from "./B05_Profile_UI_RouteEdit";
|
||||
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
|
||||
import type { EditedCurve } from "./logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import { spliceNode } from "./logic/B05_Profile_UI_RouteEdit_Edits";
|
||||
import {
|
||||
emptyEdits,
|
||||
planElevationAt,
|
||||
type AlignmentEdits,
|
||||
} from "./logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { pinStationHeight, planRouteShift } from "./logic/B05_Profile_RouteShift";
|
||||
|
||||
type Vertex = [number, number];
|
||||
/** 새 종단을 기다리는 한계(ms) — 넘으면 높이 보정 없이 한 걸음을 닫음. */
|
||||
const PROFILE_WAIT_MS = 10000;
|
||||
|
||||
/** 다음 조작의 종단 단계(가장 늦은 차례)가 끝날 때 true · 한계를 넘으면 false. */
|
||||
function nextProfile(pipe: DesignPipeline): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const done = (ok: boolean): void => {
|
||||
clearTimeout(timer);
|
||||
stop();
|
||||
resolve(ok);
|
||||
};
|
||||
const timer = setTimeout(() => done(false), PROFILE_WAIT_MS);
|
||||
const stop = pipe.subscribe((event) => {
|
||||
if (
|
||||
event.kind === "stage" &&
|
||||
event.stage === "profile" &&
|
||||
event.generation === pipe.generation
|
||||
)
|
||||
done(true);
|
||||
}, "중심점 좌우");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집기가 자료를 읽은 뒤 부름 — `columns` = 지금 편집값(산 배열 · 끼움 · 옮김이 그 자리를 고침) ·
|
||||
* `apply` = 편집기 한 걸음 · `drawn` = [그린 계획노선, 곡선표]. 편집기가 떠나면 host 를 뗌.
|
||||
*/
|
||||
export function bindRouteShift(
|
||||
host: RouteEditHost,
|
||||
projectId: string,
|
||||
columns: () => RouteEditSnapshot,
|
||||
apply: (message: string) => void,
|
||||
drawn: () => [Vertex[], EditedCurve[]],
|
||||
): void {
|
||||
setRouteShiftHost(async (chainageM, deltaM) => {
|
||||
const pipe = pagePipeline(projectId);
|
||||
if (pipe && !pipe.output("profile")) await nextProfile(pipe); // 막 세운 묶음(B06 바로 열기) — 첫 셈
|
||||
const now = columns();
|
||||
const [line, curves] = drawn();
|
||||
const plan = planRouteShift({
|
||||
planned: now.planned,
|
||||
line: line.length ? line : now.planned,
|
||||
curves,
|
||||
locked: now.apexLock,
|
||||
chainageM,
|
||||
deltaM,
|
||||
});
|
||||
if (!plan) return "노선을 아직 못 읽었습니다.";
|
||||
if (plan.kind === "locked") return "고정한 노드라 옮기지 않았습니다 — 고정을 먼저 푸세요.";
|
||||
const profile = pipe?.output("profile");
|
||||
const zBefore = profile ? planElevationAt(profile, chainageM) : null;
|
||||
const album = pageHistory();
|
||||
album?.pause();
|
||||
try {
|
||||
const routed = pipe ? nextProfile(pipe) : null;
|
||||
if (plan.kind === "move") now.planned[plan.index] = plan.to;
|
||||
else spliceNode(now, plan.index, plan.at);
|
||||
const side = deltaM > 0 ? "좌" : "우";
|
||||
apply(`횡단 중심점 — 측점 노선을 ${side}로 ${Math.abs(deltaM).toFixed(1)}m 옮겼습니다.`);
|
||||
if (!pipe || zBefore === null || !(await routed)) return null;
|
||||
const base = pipe.output("planline");
|
||||
const edits = (pipe.input("alignment") as AlignmentEdits | null) ?? emptyEdits();
|
||||
const pinned = base && pinStationHeight(base, edits, chainageM, zBefore);
|
||||
if (!pinned) return null;
|
||||
const graded = nextProfile(pipe);
|
||||
pipelineGrade(projectId, pinned);
|
||||
await graded;
|
||||
return null;
|
||||
} finally {
|
||||
album?.resume();
|
||||
}
|
||||
});
|
||||
host.onDispose(() => setRouteShiftHost(null));
|
||||
}
|
||||
@@ -43,26 +43,69 @@
|
||||
}
|
||||
|
||||
/* 본문 칸을 세로로 쌓아 그래프 스크롤러 아래에 손잡이가 붙게 한다.
|
||||
오버레이의 절대배치 기준이기도 하다. */
|
||||
테이블 겹침 판의 절대배치 기준이기도 하다. */
|
||||
.b05-route-profile__body-wrap {
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 오버레이 서브패널 — 패널 바닥(접기 손잡이 위)에 붙어 종단도·테이블 위를 덮는다
|
||||
(2026-08-04 사용자 확정). 높이는 위 경계 리사이저가 --b05-masshaul-height로 조절한다. */
|
||||
.b05-profile__masshaul-overlay {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0; /* 손잡이는 표준 삼각형(중앙, z-index 6)이라 자리를 차지하지 않는다 */
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
/* 종단 열 = 종단 칸 + 그 아래 유토곡선 판(354-1) — 배수유역도는 열 오른쪽. */
|
||||
.b05-route-profile__column {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 유토곡선 판 — 종단 칸 아래 자기 칸(354-1). 종단도 · 테이블을 덮지 않는다.
|
||||
높이는 위 경계 리사이저가 --b05-masshaul-height로 조절한다. */
|
||||
.b05-profile__masshaul-overlay {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
height: var(--b05-masshaul-height, 280px);
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: 0 -4px 12px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
/* 머리줄 — 펼침에선 곡선 위 여백(padTop)에 떠 자리를 안 먹고, 접으면 판에 머리줄만 남는다. */
|
||||
.b05-profile__masshaul-head {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 0;
|
||||
left: var(--spacing-8);
|
||||
/* 고정 Y축(z5 · 불투명) 위 — 아래면 [창 분할]이 축 띠에 가려짐 */
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-head > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > :not(.b05-profile__masshaul-head) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > .b05-profile__masshaul-head {
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 2px var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 종단 패널을 접으면 판도 함께 숨는다(배수유역도와 같음). */
|
||||
.b05-route-profile.is-collapsed .b05-profile__masshaul-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 공용 리사이저는 절대배치라 오버레이 위 경계에 그대로 앉는다. */
|
||||
@@ -142,13 +185,12 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 2차 슬라이드 손잡이 — 다른 패널들과 **같은 표준 삼각형 손잡이**를 그대로 쓴다
|
||||
(2026-08-04 사용자 지시). 패널 본문 바닥에 붙고, 오버레이(z-index 5) 위에 뜬다.
|
||||
접혔을 때 테이블 손잡이와 **일렬**로 놓이도록 중앙에서 우측으로 비켜 세운다
|
||||
(2026-08-05 사용자 지시 — 좌: 테이블, 우: 유토곡선). */
|
||||
/* 접기 손잡이 — 다른 패널들과 **같은 표준 삼각형 손잡이**를 그대로 쓴다
|
||||
(2026-08-04 사용자 지시). 판 머리줄에 앉아 접어도 닿는다(354-1). 테이블 손잡이와
|
||||
겹치지 않게 중앙에서 우측으로 비켜 세운다(2026-08-05 사용자 지시 — 좌: 테이블, 우: 유토곡선). */
|
||||
.b05-profile__masshaul-handle {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
left: calc(50% + 44px);
|
||||
z-index: 6;
|
||||
transform: translateX(-50%);
|
||||
@@ -163,8 +205,7 @@
|
||||
}
|
||||
|
||||
/* ── 테이블 오버레이 서브패널 (2026-08-05 사용자 지시) ──────────────────────
|
||||
유토곡선과 같은 뼈대. 유토곡선이 열려 있으면 그 위에 쌓인다(bottom은 JS가 조절).
|
||||
위→아래 순서: 종단도 → 테이블 → 유토곡선. */
|
||||
종단 칸 바닥에 붙어 종단도 위를 덮는다. 유토곡선은 그 아래 자기 판(354-1). */
|
||||
.b05-profile__table-overlay {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
|
||||
@@ -1,59 +1,35 @@
|
||||
/* 계획노선 편집(평면) — 종단 보기 가운데 위 본문(옛 3D 자리 · 345-3). 덮개 · 전체 화면은 걷음 —
|
||||
재계산 대기 막만 화면을 덮는다(노선 재탐색 · CLAUDE.md 5장). 색은 전부 테마 토큰을 쓴다. */
|
||||
|
||||
/* 평면 = 가운데 칸 전체(352-1) — 머리 줄 · 테두리 없음. 도구줄은 3D 와 같은 떠 있는 유리 단추
|
||||
(`.b05-route__view-controls` · ☰ 비킴 `--wf-burger-pad`) · 횡단 판은 오른쪽을 덮는 판. */
|
||||
.b05-routeedit {
|
||||
--b05-side-open: 0px; /* 횡단 판이 펴져 지도를 덮는 폭 */
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 0;
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 메인 창은 **왼쪽**, 횡단 두 판은 오른쪽 세로 칸(2026-09-12 사용자 지시 ⑱).
|
||||
좁은 화면에서는 오른쪽 칸이 접히고 메인이 폭을 다 가진다. */
|
||||
.b05-routeedit__box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-raised);
|
||||
.b05-routeedit.is-side-open {
|
||||
--b05-side-open: 460px;
|
||||
}
|
||||
|
||||
/* 머리 = 도구줄 — 왼쪽 이름 · 오른쪽 단추 묶음. */
|
||||
.b05-routeedit__head {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: var(--spacing-12);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b05-routeedit__head > h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
/* 제목행 — 왼쪽에 이름, **오른쪽 끝에 단추 묶음**(2026-09-12 사용자 지시 ⑪).
|
||||
단추는 공용 규격(`.ui-btn`)을 그대로 쓴다 — 모달이 따로 만든 크기를 걷어냈다. */
|
||||
.b05-routeedit__actions {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
/* 도구줄 — 3D 도구줄 규격 그대로 · 오른쪽 회전 단추 · 횡단 판을 비켜 줄바꿈. */
|
||||
.b05-routeedit__tools {
|
||||
right: calc(var(--spacing-12) + 112px); /* 오른쪽 위 회전 단추 자리 */
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
pointer-events: none; /* 빈 자리는 지도 조작을 통과 */
|
||||
}
|
||||
|
||||
.b05-routeedit__tools > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.b05-routeedit__canvas-wrap {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface);
|
||||
@@ -86,10 +62,10 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 조작 설명 — 지도 왼쪽 위 · 기본 접힘 · [?] 로 펼침(345-5 덧 3) · 펼치면 **2열**(사용자 지시 ⑩). */
|
||||
/* 조작 설명 — 도구줄 아래 왼쪽 · 기본 접힘 · [?] 로 펼침(345-5 덧 3 · 352-1) · 펼치면 **2열**(사용자 지시 ⑩). */
|
||||
.b05-routeedit__hint {
|
||||
top: var(--spacing-12);
|
||||
left: var(--spacing-12);
|
||||
top: calc(var(--spacing-16) + 34px + var(--spacing-8));
|
||||
left: var(--spacing-16);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-8);
|
||||
@@ -173,10 +149,10 @@
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
/* 회전 단추 — 지도 오른쪽 위(사용자 지시 ⑯). 여기만 클릭을 받는다. */
|
||||
/* 회전 단추 — 지도 오른쪽 위(사용자 지시 ⑯) · 횡단 판 왼쪽에 앉음(352-1). 여기만 클릭을 받는다. */
|
||||
.b05-routeedit__spin {
|
||||
position: absolute;
|
||||
top: var(--spacing-12);
|
||||
top: var(--spacing-16);
|
||||
right: var(--spacing-12);
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
@@ -211,18 +187,34 @@
|
||||
border-top: 2px solid var(--map-route, #f97316);
|
||||
}
|
||||
|
||||
/* 오른쪽 세로 칸 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다. 접는 칸(345-3) —
|
||||
좁은 화면은 접힌 채 시작(`_Chrome`) · [횡단] 단추로 펴고 접는다. */
|
||||
/* 오른쪽 덮는 판 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다(352-1). 지도 위에 얹고
|
||||
아래 종단 판(z 3)이 그 위를 덮는다. 접는 칸(345-3) — 좁은 화면은 접힌 채 시작(`_Chrome`) ·
|
||||
[횡단] 단추로 펴고 접는다. 빈 자리는 지도 조작을 통과. */
|
||||
.b05-routeedit__side-wrap {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: calc(var(--spacing-16) + 34px + var(--spacing-8)); /* 도구줄 · 회전 단추 줄 아래 */
|
||||
right: var(--spacing-12);
|
||||
bottom: var(--spacing-12);
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b05-routeedit__side-wrap.is-collapsed {
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.b05-routeedit__side-wrap > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.b05-routeedit__side-toggle {
|
||||
align-self: flex-end;
|
||||
min-width: 56px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.b05-routeedit__side {
|
||||
@@ -323,21 +315,20 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ㉓ 거리 재기와 되돌리기 사이 구분선. */
|
||||
.b05-routeedit__divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
margin: 0 var(--spacing-4, 4px);
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임. */
|
||||
/* 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임 · 글에 유리 바탕(352-1). */
|
||||
.b05-routeedit__terrain {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4, 4px);
|
||||
height: 34px;
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent);
|
||||
border-radius: var(--radius-8, 6px);
|
||||
background: color-mix(in srgb, var(--color-surface-raised) 72%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted, inherit);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.b05-routeedit__terrain[hidden] {
|
||||
@@ -347,7 +338,7 @@
|
||||
/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창(뜬 종단 판이 덮으면 가려진 채 · 349-0). 닫으면 잰 것이 지워진다. */
|
||||
.b05-routeedit__measure {
|
||||
position: absolute;
|
||||
right: var(--spacing-12);
|
||||
right: calc(var(--spacing-12) + var(--b05-side-open));
|
||||
bottom: var(--spacing-12);
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
@@ -461,11 +452,3 @@
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* 「거리 재기」가 켜진 동안 — 단추가 눌린 꼴로 남는다(2026-09-12 사용자 지시 ⑥).
|
||||
`is-active` 는 예전에도 붙고 있었으나 꼴이 없어 겉모습이 그대로였다. */
|
||||
.b05-routeedit__actions .ui-btn.is-active {
|
||||
border-color: transparent;
|
||||
background: var(--color-primary, #7c3aed);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/* 평면 등고선 자물쇠(353-2) — 커서 옆에 뜨는 작은 단추 · 자리는 `_ContourLock.ts` 가 잡음. */
|
||||
.b05-contourlock {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.b05-contourlock[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 풀림 = 고리가 들림 · 잠김 = 고른 등고선 색(보라)으로 채움 */
|
||||
.b05-contourlock[aria-pressed="false"] .b05-contourlock__shackle {
|
||||
transform: translate(2.5px, -1px) rotate(18deg);
|
||||
transform-origin: 11px 7px;
|
||||
}
|
||||
|
||||
.b05-contourlock[aria-pressed="true"] {
|
||||
border-color: var(--map-flow-arrow);
|
||||
background-color: var(--map-flow-arrow);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { createTerrainCompass } from "@ui/ui_template_compass";
|
||||
import { contourMaterials, isMajorContour } from "@ui/ui_template_contour3d";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -339,21 +340,20 @@ export function createRouteViewer(): RouteViewer {
|
||||
([x, y, z]) => new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)),
|
||||
);
|
||||
if (points.length < 2) return;
|
||||
const bucket = contour.level % (interval * 5) === 0 ? majorPoints : minorPoints;
|
||||
const bucket = isMajorContour(contour.level) ? majorPoints : minorPoints;
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
bucket.push(points[index], points[index + 1]);
|
||||
}
|
||||
});
|
||||
// 주곡선 = 5 m 고정 · 1 m 는 옅게(평면 그림과 같은 색 토큰 · 353-3)
|
||||
const materials = contourMaterials();
|
||||
[
|
||||
{ points: minorPoints, color: 0xf59e0b },
|
||||
{ points: majorPoints, color: 0xd97706 },
|
||||
].forEach(({ points, color }) => {
|
||||
{ points: minorPoints, material: materials.minor },
|
||||
{ points: majorPoints, material: materials.major },
|
||||
].forEach(({ points, material }) => {
|
||||
if (points.length === 0) return;
|
||||
contours.add(
|
||||
new THREE.LineSegments(
|
||||
new THREE.BufferGeometry().setFromPoints(points),
|
||||
new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.75 }),
|
||||
),
|
||||
new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(points), material),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_ContourLock.ts
|
||||
* 평면 등고선 고정(353-2) — 고른 높이 하나를 쥐고, 잠긴 동안 다른 지정을 막는 규칙.
|
||||
*
|
||||
* 높이값으로 쥔다(가닥 번호는 지형을 바꾸면 달라짐) · 페이지 동안만(저장 안 함).
|
||||
* 순수 계산 — 화면 · three · fetch · sessionStorage 없음. 단추는 `B05_Profile_UI_RouteEdit_ContourLock`.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 가닥마다 높이(모르면 null). */
|
||||
export type ContourLevels = ReadonlyArray<number | null>;
|
||||
|
||||
/** 자물쇠 단추 — 보일지 · 눌린 모양인지. */
|
||||
export interface LockButtonState {
|
||||
visible: boolean;
|
||||
pressed: boolean;
|
||||
}
|
||||
|
||||
/** 그 높이의 첫 가닥 번호 — 없으면 -1(지형을 바꾼 뒤 잠근 높이 다시 짚기). */
|
||||
export function featureOfLevel(levels: ContourLevels, level: number | null): number {
|
||||
if (level === null) return -1;
|
||||
return levels.findIndex((value) => value === level);
|
||||
}
|
||||
|
||||
/** 커서 아래 가닥 높이 · 잠근 높이 → 단추 — 안 잠김 = 높이 있는 가닥 위에서 · 잠김 = 그 높이 위에서만. */
|
||||
export function lockButtonState(
|
||||
hoverLevel: number | null,
|
||||
lockedLevel: number | null,
|
||||
): LockButtonState {
|
||||
if (lockedLevel !== null) return { visible: hoverLevel === lockedLevel, pressed: true };
|
||||
return { visible: hoverLevel !== null, pressed: false };
|
||||
}
|
||||
|
||||
/** 단추 누름 — 잠김이면 풂(null) · 아니면 그 가닥 높이로 잠금. */
|
||||
export function toggleLock(lockedLevel: number | null, targetLevel: number | null): number | null {
|
||||
return lockedLevel !== null ? null : targetLevel;
|
||||
}
|
||||
|
||||
/** 잠긴 동안은 고른 가닥이 안 바뀜(다른 가닥 · 빈 곳 눌러도). */
|
||||
export const canPickContour = (lockedLevel: number | null): boolean => lockedLevel === null;
|
||||
@@ -3,7 +3,8 @@
|
||||
* 평면 발자국 — 측점 횡단 설계 결과를 평면 선으로(도로 끝 좌 · 우 · 비탈 끝 깎기 · 쌓기 · 눈금) (346-2)
|
||||
*
|
||||
* ⚠⚠ 짝: `B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py`
|
||||
* (`route_frame` · `side_is_cut` · `slope_end` · `slope_pieces`) — 한쪽만 고치면
|
||||
* (`route_frame` · `route_normal` · `side_is_cut` · `slope_end` · `slope_pieces` 와 그 아래
|
||||
* 단조 3차 보간 · 촘촘히 · 접힘 버림 · 353-1) — 한쪽만 고치면
|
||||
* 종단 · 횡단 편집 중 평면과 B07 계획평면도가 갈린다.
|
||||
* `footprintRecord` = `B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄.
|
||||
* 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py`
|
||||
@@ -105,18 +106,6 @@ export function routeFrame(route: Array<[number, number, number]>, chainage: num
|
||||
return [x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length];
|
||||
}
|
||||
|
||||
function interp(records: Array<Record<string, number>>, chainage: number, key: string): number {
|
||||
const chainages = records.map((record) => record.chainage_m);
|
||||
const index = bisectLeft(chainages, chainage);
|
||||
if (index <= 0) return records[0][key];
|
||||
if (index >= records.length) return records[records.length - 1][key];
|
||||
const before = records[index - 1];
|
||||
const after = records[index];
|
||||
const span = after.chainage_m - before.chainage_m;
|
||||
const ratio = span > 0 ? (chainage - before.chainage_m) / span : 0.0;
|
||||
return before[key] + (after[key] - before[key]) * ratio;
|
||||
}
|
||||
|
||||
function groundOf(samples: FootprintPoint[]): Array<[number, number]> {
|
||||
return samples
|
||||
.filter(
|
||||
@@ -216,13 +205,187 @@ function splitRuns(points: Line, flags: boolean[]): [Line[], Line[]] {
|
||||
return [runs.true.filter((run) => run.length >= 2), runs.false.filter((run) => run.length >= 2)];
|
||||
}
|
||||
|
||||
/** 절/성토선 매끄럽게(353-1) — 네 선 중 하나라도 사이 가운데 점에서 이보다 꺾이면 반으로 나눔(°). 짝 같은 이름. */
|
||||
export const SPLIT_TURN_DEG = 2.0;
|
||||
/** 나눔 깊이 상한(2 m → 12.5 cm). */
|
||||
export const SPLIT_DEPTH = 4;
|
||||
/** 사이 점 법선을 재는 앞뒤 거리(m) — 측점 횡단 틀(`stationFrameAt`)과 같은 값. */
|
||||
export const NORMAL_PROBE_M = 1.0;
|
||||
|
||||
type Frame = [number, number, number, number];
|
||||
|
||||
/** 노선 위 그 자리와 좌측 법선 — 법선은 앞뒤 ±1 m 두 점을 잇는 방향. 짝: `route_normal`. */
|
||||
export function routeNormal(route: Array<[number, number, number]>, chainage: number): Frame {
|
||||
const [x, y, nx, ny] = routeFrame(route, chainage);
|
||||
const first = route[0][0];
|
||||
const last = route[route.length - 1][0];
|
||||
let before = Math.max(first, chainage - NORMAL_PROBE_M);
|
||||
let after = Math.min(last, chainage + NORMAL_PROBE_M);
|
||||
if (after - before <= 1e-9) {
|
||||
before = Math.max(first, chainage - 1e-3);
|
||||
after = Math.min(last, chainage + 1e-3);
|
||||
}
|
||||
const [x0, y0] = routeFrame(route, before);
|
||||
const [x1, y1] = routeFrame(route, after);
|
||||
const length = Math.hypot(x1 - x0, y1 - y0);
|
||||
if (length <= 1e-9) return [x, y, nx, ny];
|
||||
return [x, y, -(y1 - y0) / length, (x1 - x0) / length];
|
||||
}
|
||||
|
||||
function bisectRight(values: number[], target: number): number {
|
||||
let low = 0;
|
||||
let high = values.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >> 1;
|
||||
if (target < values[middle]) high = middle;
|
||||
else low = middle + 1;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
type Knots = [number[], number[], number[]];
|
||||
|
||||
/** 단조 3차(Fritsch–Carlson) 꺾임점 기울기 — 이웃 값 사이를 넘지 않음. 짝: `_pchip_slopes`. */
|
||||
function pchipSlopes(xs: number[], ys: number[]): number[] {
|
||||
const n = xs.length;
|
||||
if (n < 2) return xs.map(() => 0);
|
||||
const h = xs.slice(1).map((x, i) => x - xs[i]);
|
||||
const delta = h.map((span, i) => (ys[i + 1] - ys[i]) / span);
|
||||
const slopes = [delta[0], ...new Array<number>(n - 2).fill(0), delta[n - 2]];
|
||||
for (let k = 1; k < n - 1; k += 1) {
|
||||
if (delta[k - 1] * delta[k] > 0) {
|
||||
const w1 = 2 * h[k] + h[k - 1];
|
||||
const w2 = h[k] + 2 * h[k - 1];
|
||||
slopes[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k]);
|
||||
}
|
||||
}
|
||||
return slopes;
|
||||
}
|
||||
|
||||
/** 꺾임점 값을 그대로 지나는 단조 3차 보간 — 범위 밖은 끝값. 짝: `_pchip`. */
|
||||
function pchip([xs, ys, slopes]: Knots, x: number): number {
|
||||
if (x <= xs[0]) return ys[0];
|
||||
if (x >= xs[xs.length - 1]) return ys[ys.length - 1];
|
||||
const i = bisectRight(xs, x) - 1;
|
||||
const h = xs[i + 1] - xs[i];
|
||||
const t = (x - xs[i]) / h;
|
||||
return (
|
||||
(1 + 2 * t) * (1 - t) ** 2 * ys[i] +
|
||||
t * (1 - t) ** 2 * h * slopes[i] +
|
||||
t * t * (3 - 2 * t) * ys[i + 1] +
|
||||
t * t * (t - 1) * h * slopes[i + 1]
|
||||
);
|
||||
}
|
||||
|
||||
const knots = (xs: number[], ys: number[]): Knots => [xs, ys, pchipSlopes(xs, ys)];
|
||||
|
||||
interface SideCurves {
|
||||
chainages: number[];
|
||||
flags: boolean[];
|
||||
switches: number[];
|
||||
edge: Knots;
|
||||
width: Knots;
|
||||
}
|
||||
|
||||
/** 한 쪽 도로 끝 · 비탈 폭 보간 + 깎기↔쌓기 바뀜 자리(폭 0 = 비탈 끝이 도로 끝으로 모임). 짝: `_side_curves`. */
|
||||
function sideCurves(records: FootprintRecord[], side: Side): SideCurves {
|
||||
const chainages = records.map((record) => record.chainage_m);
|
||||
const edges = records.map((record) => record[`${side}_edge_m`]);
|
||||
const widths = records.map((record) => record[`${side}_end_m`] - record[`${side}_edge_m`]);
|
||||
const flags = records.map((record) => Boolean(record[`${side}_cut`]));
|
||||
const switches: number[] = [];
|
||||
const widthX = [chainages[0]];
|
||||
const widthY = [widths[0]];
|
||||
for (let i = 1; i < records.length; i += 1) {
|
||||
const c0 = chainages[i - 1];
|
||||
const c1 = chainages[i];
|
||||
const w0 = Math.abs(widths[i - 1]);
|
||||
const w1 = Math.abs(widths[i]);
|
||||
const at = c0 + (c1 - c0) * (w0 + w1 > 1e-9 ? w0 / (w0 + w1) : 0.5);
|
||||
switches.push(at);
|
||||
if (flags[i - 1] !== flags[i] && c0 + 1e-9 < at && at < c1 - 1e-9) {
|
||||
widthX.push(at);
|
||||
widthY.push(0.0);
|
||||
}
|
||||
widthX.push(c1);
|
||||
widthY.push(widths[i]);
|
||||
}
|
||||
return {
|
||||
chainages,
|
||||
flags,
|
||||
switches,
|
||||
edge: knots(chainages, edges),
|
||||
width: knots(widthX, widthY),
|
||||
};
|
||||
}
|
||||
|
||||
/** 그 자리 깎기 여부 — 바뀜 자리부터 뒤 측점 것(바뀌는 점은 앞뒤 조각이 나눠 가짐). 짝: `_cut_at`. */
|
||||
function cutAt(curves: SideCurves, chainage: number): boolean {
|
||||
const { flags } = curves;
|
||||
const i = bisectRight(curves.chainages, chainage);
|
||||
if (i === 0) return flags[0];
|
||||
if (i === flags.length) return flags[flags.length - 1];
|
||||
if (flags[i - 1] === flags[i]) return flags[i];
|
||||
return chainage < curves.switches[i - 1] ? flags[i - 1] : flags[i];
|
||||
}
|
||||
|
||||
/** 진행을 거스르는 점(안쪽 비탈 끝이 곡선 중심을 넘어 접힘)을 버린 남는 점 번호. 짝: `_forward_only`. */
|
||||
function forwardOnly(points: Line, frames: Frame[]): number[] {
|
||||
const kept = points.length ? [0] : [];
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
const last = points[kept[kept.length - 1]];
|
||||
const [, , nx, ny] = frames[i];
|
||||
if ((points[i][0] - last[0]) * ny - (points[i][1] - last[1]) * nx > 1e-6) kept.push(i);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
function turnDeg(a: number[], m: number[], b: number[]): number {
|
||||
const u = [m[0] - a[0], m[1] - a[1]];
|
||||
const v = [b[0] - m[0], b[1] - m[1]];
|
||||
return Math.abs(
|
||||
(Math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]) * 180) / Math.PI,
|
||||
);
|
||||
}
|
||||
|
||||
/** 곡선 · 폭이 가파르게 바뀌는 자리 — 가운데 점에서 많이 꺾이는 사이를 반씩 나눔. 짝: `_densify`. */
|
||||
function densify(
|
||||
chainages: number[],
|
||||
frameOf: (chainage: number) => Frame,
|
||||
place: (chainage: number, frame: Frame) => Line,
|
||||
): [number[], Frame[]] {
|
||||
const split = (c0: number, p0: Line, c1: number, p1: Line, depth: number): number[] => {
|
||||
if (depth >= SPLIT_DEPTH) return [];
|
||||
const middle = (c0 + c1) / 2.0;
|
||||
const pm = place(middle, frameOf(middle));
|
||||
// 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음
|
||||
const bends = pm.some((m, k) => {
|
||||
const turn = turnDeg(p0[k], m, p1[k]);
|
||||
return SPLIT_TURN_DEG < turn && turn < 90.0;
|
||||
});
|
||||
if (!bends) return [];
|
||||
return [
|
||||
...split(c0, p0, middle, pm, depth + 1),
|
||||
middle,
|
||||
...split(middle, pm, c1, p1, depth + 1),
|
||||
];
|
||||
};
|
||||
const points = chainages.map((chainage) => place(chainage, frameOf(chainage)));
|
||||
const out = chainages.slice(0, 1);
|
||||
for (let i = 1; i < chainages.length; i += 1) {
|
||||
out.push(...split(chainages[i - 1], points[i - 1], chainages[i], points[i], 0));
|
||||
out.push(chainages[i]);
|
||||
}
|
||||
return [out, out.map(frameOf)];
|
||||
}
|
||||
|
||||
interface Sampled {
|
||||
lines: Record<(typeof KEYS)[number], Line>;
|
||||
cut: Record<Side, boolean[]>;
|
||||
ticks: Line[];
|
||||
}
|
||||
|
||||
/** 측점 줄 → 측점 · 눈금 간격마다 네 선 · 쪽마다 깎기 여부 · 눈금. `slope_pieces` 의 몸통. */
|
||||
/** 측점 줄 → 네 선 · 쪽마다 깎기 여부 · 눈금. `slope_pieces` 의 몸통. */
|
||||
function sampleLines(
|
||||
route: Array<[number, number, number]>,
|
||||
input: FootprintRecord[],
|
||||
@@ -230,49 +393,94 @@ function sampleLines(
|
||||
endM: number,
|
||||
stepM: number,
|
||||
): Sampled | null {
|
||||
const records = input
|
||||
const sorted = input
|
||||
.map((record) => ({
|
||||
...record,
|
||||
left_cut: Number(Boolean(record.left_cut)),
|
||||
right_cut: Number(Boolean(record.right_cut)),
|
||||
left_cut: Boolean(record.left_cut),
|
||||
right_cut: Boolean(record.right_cut),
|
||||
}))
|
||||
.sort((a, b) => a.chainage_m - b.chainage_m) as unknown as Array<Record<string, number>>;
|
||||
if (route.length < 2 || !records.length) return null;
|
||||
const byChainage = new Map<number, Record<string, number>>();
|
||||
.sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
if (route.length < 2 || !sorted.length) return null;
|
||||
// 같은 누가거리 측점은 뒤 것 하나만
|
||||
const records: FootprintRecord[] = [];
|
||||
for (const record of sorted) {
|
||||
const last = records[records.length - 1];
|
||||
if (last && record.chainage_m - last.chainage_m <= 1e-9) records[records.length - 1] = record;
|
||||
else records.push(record);
|
||||
}
|
||||
const curves = { left: sideCurves(records, "left"), right: sideCurves(records, "right") };
|
||||
const byChainage = new Map<number, FootprintRecord>();
|
||||
for (const record of records) byChainage.set(roundTo(record.chainage_m, 3), record);
|
||||
const samples = new Set<number>();
|
||||
const base = new Set<number>();
|
||||
for (const record of records) {
|
||||
if (startM <= record.chainage_m && record.chainage_m <= endM)
|
||||
samples.add(roundTo(record.chainage_m, 3));
|
||||
base.add(roundTo(record.chainage_m, 3));
|
||||
}
|
||||
for (let step = startM; step <= endM; step += stepM) samples.add(roundTo(step, 3));
|
||||
const lines = Object.fromEntries(KEYS.map((key) => [key, []])) as unknown as Sampled["lines"];
|
||||
const cut: Record<Side, boolean[]> = { left: [], right: [] };
|
||||
for (let step = startM; step <= endM; step += stepM) base.add(roundTo(step, 3));
|
||||
const all = new Set(base);
|
||||
for (const side of SIDES) {
|
||||
const { flags, switches } = curves[side];
|
||||
switches.forEach((at, i) => {
|
||||
if (flags[i] !== flags[i + 1] && startM <= at && at <= endM) all.add(at);
|
||||
});
|
||||
}
|
||||
const frameOf = (chainage: number): Frame => {
|
||||
const record = byChainage.get(roundTo(chainage, 3));
|
||||
const { x, y, nx, ny } = record ?? {};
|
||||
if (x !== undefined && y !== undefined && nx !== undefined && ny !== undefined)
|
||||
return [x, y, nx, ny];
|
||||
return routeNormal(route, chainage);
|
||||
};
|
||||
const valuesAt = (chainage: number): Record<string, number> => {
|
||||
const values: Record<string, number> = {};
|
||||
for (const side of SIDES) {
|
||||
const edge = pchip(curves[side].edge, chainage);
|
||||
values[`${side}_edge_m`] = edge;
|
||||
values[`${side}_end_m`] = edge + pchip(curves[side].width, chainage);
|
||||
}
|
||||
return values;
|
||||
};
|
||||
const place = (chainage: number, [x, y, nx, ny]: Frame): Line => {
|
||||
const values = valuesAt(chainage);
|
||||
return KEYS.map((key) => [x + nx * values[key], y + ny * values[key]]);
|
||||
};
|
||||
const [chainages, frames] = densify(
|
||||
[...all].sort((a, b) => a - b),
|
||||
frameOf,
|
||||
place,
|
||||
);
|
||||
const offsets = chainages.map(valuesAt);
|
||||
const raw = Object.fromEntries(KEYS.map((key) => [key, []])) as unknown as Sampled["lines"];
|
||||
frames.forEach(([x, y, nx, ny], i) => {
|
||||
for (const key of KEYS)
|
||||
raw[key].push([roundTo(x + nx * offsets[i][key], 4), roundTo(y + ny * offsets[i][key], 4)]);
|
||||
});
|
||||
const kept = Object.fromEntries(
|
||||
KEYS.map((key) => [key, forwardOnly(raw[key], frames)]),
|
||||
) as unknown as Record<(typeof KEYS)[number], number[]>;
|
||||
const lines = Object.fromEntries(
|
||||
KEYS.map((key) => [key, kept[key].map((i) => raw[key][i])]),
|
||||
) as unknown as Sampled["lines"];
|
||||
const cut: Record<Side, boolean[]> = {
|
||||
left: kept.left_end_m.map((i) => cutAt(curves.left, chainages[i])),
|
||||
right: kept.right_end_m.map((i) => cutAt(curves.right, chainages[i])),
|
||||
};
|
||||
// 눈금은 도로 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아(납품
|
||||
// 도면 관례) · 접혀 버린 자리는 뺌.
|
||||
const ticks: Line[] = [];
|
||||
[...samples]
|
||||
const keptSets = { left: new Set(kept.left_end_m), right: new Set(kept.right_end_m) };
|
||||
const position = new Map(chainages.map((chainage, i) => [chainage, i]));
|
||||
[...base]
|
||||
.sort((a, b) => a - b)
|
||||
.forEach((chainage, index) => {
|
||||
const record = byChainage.get(chainage);
|
||||
const [x, y, nx, ny] =
|
||||
record && ["x", "y", "nx", "ny"].every((k) => record[k] !== undefined)
|
||||
? [record.x, record.y, record.nx, record.ny]
|
||||
: routeFrame(route, chainage);
|
||||
const offsets = Object.fromEntries(KEYS.map((key) => [key, interp(records, chainage, key)]));
|
||||
const points = Object.fromEntries(
|
||||
KEYS.map((key) => [
|
||||
key,
|
||||
[roundTo(x + nx * offsets[key], 4), roundTo(y + ny * offsets[key], 4)],
|
||||
]),
|
||||
);
|
||||
for (const key of KEYS) lines[key].push(points[key]);
|
||||
for (const side of SIDES) cut[side].push(interp(records, chainage, `${side}_cut`) > 0.5);
|
||||
// 눈금은 도로 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아(납품 도면 관례).
|
||||
const i = position.get(chainage) ?? 0;
|
||||
const [x, y, nx, ny] = frames[i];
|
||||
for (const side of SIDES) {
|
||||
const edge = offsets[`${side}_edge_m`];
|
||||
const end = offsets[`${side}_end_m`];
|
||||
if (Math.abs(end - edge) < 0.05) continue;
|
||||
const edge = offsets[i][`${side}_edge_m`];
|
||||
const end = offsets[i][`${side}_end_m`];
|
||||
if (Math.abs(end - edge) < 0.05 || !keptSets[side].has(i)) continue;
|
||||
const reach = index % 2 === 0 ? end : edge + (end - edge) / 2.0;
|
||||
ticks.push([points[`${side}_edge_m`], [x + nx * reach, y + ny * reach]]);
|
||||
ticks.push([raw[`${side}_edge_m`][i], [x + nx * reach, y + ny * reach]]);
|
||||
}
|
||||
});
|
||||
return { lines, cut, ticks };
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_RouteShift.ts
|
||||
* 횡단 중심점 좌우 이동(356-3) — 측점 자리 노선을 좌측 축으로 Δ m 옮길 노드 편집을 고르고 · 그 측점
|
||||
* 계획고를 옮기기 전 값으로 되돌릴 계획고 편집을 냄.
|
||||
*
|
||||
* · P = 측점 중심 · L = 좌측 단위 벡터(`stationFrameAt` · +offset = 좌)
|
||||
* · P 에서 0.5 m 안 노드 = 그 노드 + Δ·L(고정 노드면 안 옮김)
|
||||
* · 곡선(BC ~ EC) 안 측점 = 그 교각점 + Δ·L(고정이면 안 옮김)
|
||||
* · 둘 다 아니면 P + Δ·L 을 그 자리 노드 토막에 끼움
|
||||
* · 높이 = 옮기기 전 계획고 − 옮긴 뒤 계획고 만큼 그 측점 계획고 편집(`adjustStation`)
|
||||
*
|
||||
* 브라우저가 고르는 편집값일 뿐 — 서버는 [저장] 때 노드 · 계획고 편집을 받아 정본으로 다시 셈(짝 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
adjustStation,
|
||||
buildAlignment,
|
||||
planElevationAt,
|
||||
type AlignmentBase,
|
||||
type AlignmentEdits,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { curveSpans, stationFrameAt, type CurveEnds } from "./B05_Profile_UI_RouteEdit_Frame";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
/** 측점 중심에서 이 안의 노드는 끼우지 않고 옮김(m). */
|
||||
export const NODE_SNAP_M = 0.5;
|
||||
/** 이보다 작은 높이 차는 보정 안 함(m). */
|
||||
export const PIN_TOLERANCE_M = 5e-4;
|
||||
|
||||
export type RouteShiftPlan =
|
||||
| { kind: "move"; index: number; to: Vertex }
|
||||
| { kind: "insert"; index: number; at: Vertex }
|
||||
| { kind: "locked"; index: number };
|
||||
|
||||
export interface RouteShiftInput {
|
||||
/** 노드(꺾임점). */
|
||||
planned: ReadonlyArray<Vertex>;
|
||||
/** 그려진 계획노선(원호 펴진 폴리라인) — 측점 누가거리의 기준. */
|
||||
line: ReadonlyArray<Vertex>;
|
||||
/** 곡선표 — `node_first` = 그 곡선 교각점 노드 자리. */
|
||||
curves: ReadonlyArray<CurveEnds & { node_first: number }>;
|
||||
/** 노드마다 고정. */
|
||||
locked: ReadonlyArray<boolean>;
|
||||
chainageM: number;
|
||||
/** 좌(+) · 우(−) 이동(m). */
|
||||
deltaM: number;
|
||||
}
|
||||
|
||||
/** 점에서 가장 가까운 노드 토막 차례(앞 노드 자리). */
|
||||
function nearestSegment(nodes: ReadonlyArray<Vertex>, point: Vertex): number {
|
||||
let best = 0;
|
||||
let bestGap = Infinity;
|
||||
for (let i = 0; i < nodes.length - 1; i += 1) {
|
||||
const [ax, ay] = nodes[i];
|
||||
const [vx, vy] = [nodes[i + 1][0] - ax, nodes[i + 1][1] - ay];
|
||||
const length2 = vx * vx + vy * vy || 1;
|
||||
const t = Math.min(1, Math.max(0, ((point[0] - ax) * vx + (point[1] - ay) * vy) / length2));
|
||||
const gap = Math.hypot(ax + vx * t - point[0], ay + vy * t - point[1]);
|
||||
if (gap < bestGap) [best, bestGap] = [i, gap];
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 노드 편집 고름 — 노선이 없으면 null. */
|
||||
export function planRouteShift(input: RouteShiftInput): RouteShiftPlan | null {
|
||||
const { planned, line, curves, locked, chainageM, deltaM } = input;
|
||||
const frame = planned.length >= 2 ? stationFrameAt(line, chainageM) : null;
|
||||
if (!frame) return null;
|
||||
const { origin, left } = frame;
|
||||
const shifted = ([x, y]: Vertex): Vertex => [x + left[0] * deltaM, y + left[1] * deltaM];
|
||||
const move = (index: number): RouteShiftPlan =>
|
||||
locked[index]
|
||||
? { kind: "locked", index }
|
||||
: { kind: "move", index, to: shifted(planned[index]) };
|
||||
const near = planned.findIndex(
|
||||
([x, y]) => Math.hypot(x - origin[0], y - origin[1]) <= NODE_SNAP_M,
|
||||
);
|
||||
if (near >= 0) return move(near);
|
||||
const curve = curves.find((one) => {
|
||||
const [span] = curveSpans(line, [one]);
|
||||
return span && chainageM > span[0] && chainageM < span[1];
|
||||
});
|
||||
if (curve && planned[curve.node_first]) return move(curve.node_first);
|
||||
return { kind: "insert", index: nearestSegment(planned, origin) + 1, at: shifted(origin) };
|
||||
}
|
||||
|
||||
/** 그 측점 계획고를 `zBefore` 로 되돌린 계획고 편집 — 차가 없으면 null. */
|
||||
export function pinStationHeight(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
chainageM: number,
|
||||
zBefore: number,
|
||||
): AlignmentEdits | null {
|
||||
const drift = zBefore - planElevationAt(buildAlignment(base, edits), chainageM);
|
||||
return Math.abs(drift) < PIN_TOLERANCE_M ? null : adjustStation(base, edits, chainageM, drift);
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
* - 편집 델타는 항상 **자동 선형(base_pvi) 기준**이라 키를 지우면 정확히 원복된다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pivotAxes } from "./B05_Profile_UI_Profile_Pivot";
|
||||
|
||||
export interface AlignmentPolicy {
|
||||
station_interval_m: number;
|
||||
curve_radius_ratio: number;
|
||||
@@ -552,6 +554,7 @@ const FEEDBACK_TOLERANCE_M = 1e-4;
|
||||
* 변화점이 새로 생기면 종단곡선의 중앙종거만큼 계획고가 함께 내려가(또는 올라가)
|
||||
* 버튼 1클릭이 정확히 0.1m가 되지 않는다. **화면에 보이는 계획고**가 정확히 delta만큼
|
||||
* 움직이도록 중앙종거 변화분을 몇 번 되먹여 보정한다.
|
||||
* 누른 점이 꺾임이면 이웃 꺾임(`pivotAxes`)을 축으로 사이 측점이 새 직선 위로 따라 돈다(355-1).
|
||||
*/
|
||||
export function adjustStation(
|
||||
base: AlignmentBase,
|
||||
@@ -562,17 +565,24 @@ export function adjustStation(
|
||||
const key = chainageKey(chainageM);
|
||||
const current = buildAlignment(base, edits);
|
||||
const target = planElevationAt(current, chainageM) + delta;
|
||||
const baseElevation = interpolate(
|
||||
base.basePvi.map((node) => node.chainage_m),
|
||||
base.basePvi.map((node) => node.elevation_m),
|
||||
chainageM,
|
||||
);
|
||||
const baseS = base.basePvi.map((node) => node.chainage_m);
|
||||
const baseZ = base.basePvi.map((node) => node.elevation_m);
|
||||
const baseElevation = interpolate(baseS, baseZ, chainageM);
|
||||
let offset =
|
||||
(edits.station_offsets[key] ?? planElevationAt(current, chainageM) - baseElevation) + delta;
|
||||
const withOffset = (value: number): AlignmentEdits => ({
|
||||
...edits,
|
||||
station_offsets: { ...edits.station_offsets, [key]: Number(value.toFixed(6)) },
|
||||
});
|
||||
const axes = pivotAxes(current, chainageM);
|
||||
const withOffset = (value: number): AlignmentEdits => {
|
||||
const offsets = { ...edits.station_offsets, [key]: Number(value.toFixed(6)) };
|
||||
const radii = { ...edits.curve_radii };
|
||||
axes?.inner.forEach((chainage) => {
|
||||
const [axisM, axisZ] = chainage < chainageM ? [axes.fromM, axes.fromZ] : [axes.toM, axes.toZ];
|
||||
const z =
|
||||
axisZ + ((baseElevation + value - axisZ) * (chainage - axisM)) / (chainageM - axisM);
|
||||
offsets[chainageKey(chainage)] = Number((z - interpolate(baseS, baseZ, chainage)).toFixed(6));
|
||||
delete radii[chainageKey(chainage)];
|
||||
});
|
||||
return { ...edits, station_offsets: offsets, curve_radii: radii };
|
||||
};
|
||||
let next = withOffset(offset);
|
||||
for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) {
|
||||
const error = target - planElevationAt(buildAlignment(base, next), chainageM);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Pivot.ts
|
||||
* 종단 ▲▼ 축 찾기(355-1 · 요구 235-15) — 누른 점이 꺾임(R 있는 점)이면 좌우로 같은
|
||||
* 기울기 마디를 건너 다음 꺾임(없으면 시점 · 종점)을 축으로 삼는다. 사이 측점은
|
||||
* 새 직선(축 → 누른 점 → 축) 위로 따라 돈다(`adjustStation`).
|
||||
*
|
||||
* 처음 계획선은 마디마다 꺾여 축 = 누른 점 ±1 → 사이 측점 없음(옛 동작 그대로).
|
||||
* `B05_Profile_UI_Profile_Alignment` 를 타입으로만 읽는다(순환 import 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
/** 같은 직선으로 볼 기울기 차이 — `B05_Profile_UI_Profile_Straighten` 과 같은 값. */
|
||||
const COLLINEAR_EPSILON = 1e-6;
|
||||
const SAME_STATION_M = 1e-6;
|
||||
|
||||
export interface PivotAxes {
|
||||
fromM: number;
|
||||
fromZ: number;
|
||||
toM: number;
|
||||
toZ: number;
|
||||
/** 두 축 사이(끝 · 누른 점 제외) 변화점 chainage. */
|
||||
inner: number[];
|
||||
}
|
||||
|
||||
/** 누른 점이 꺾임이면 두 축과 사이 변화점 — 직선 안쪽 · 시종점 · 변화점 아님이면 null. */
|
||||
export function pivotAxes(alignment: ProfileAlignment, chainageM: number): PivotAxes | null {
|
||||
const { pvi, segments } = alignment;
|
||||
const index = pvi.findIndex((node) => Math.abs(node.chainage_m - chainageM) < SAME_STATION_M);
|
||||
if (index <= 0 || index >= pvi.length - 1) return null;
|
||||
const grade = (at: number): number => {
|
||||
const segment = segments[at];
|
||||
return segment && segment.length_m > 1e-12 ? segment.height_m / segment.length_m : 0;
|
||||
};
|
||||
const same = (a: number, b: number): boolean => Math.abs(grade(a) - grade(b)) < COLLINEAR_EPSILON;
|
||||
if (same(index - 1, index)) return null;
|
||||
let from = index - 1;
|
||||
while (from > 0 && same(from - 1, index - 1)) from -= 1;
|
||||
let to = index + 1;
|
||||
while (to < pvi.length - 1 && same(to, index)) to += 1;
|
||||
return {
|
||||
fromM: pvi[from].chainage_m,
|
||||
fromZ: pvi[from].elevation_m,
|
||||
toM: pvi[to].chainage_m,
|
||||
toZ: pvi[to].elevation_m,
|
||||
inner: pvi
|
||||
.slice(from + 1, to)
|
||||
.map((node) => node.chainage_m)
|
||||
.filter((chainage) => Math.abs(chainage - chainageM) >= SAME_STATION_M),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Center.ts
|
||||
* 횡단 카드 중심점 고름(356-3) — 보라 선 고름(`_Cross_Design_Pick`)의 끝 조각(키 `center`).
|
||||
*
|
||||
* · 판정 원 = 중심 십자 자리 · 노면 판정보다 위(`stackPickTargets`) · 다른 고름과 같은 칸(하나만)
|
||||
* · 고르면 ▲▼ = 종단 계획고(지금 길 · 종단도 듣개) · ◀▶ · 가로 끌기 = 그 측점 노선을 좌우로
|
||||
* · 끄는 동안 · 키를 잇는 동안은 십자만 옮겨 보이고 · 놓을 때(키는 멈춘 뒤) 한 번 보냄
|
||||
* · 좌우 = 노선 편집기가 등록한 host(`setRouteShiftHost` · B05 `_RouteEdit_Shift`) — B06 을 바로 열어
|
||||
* 편집기가 없으면 종단 보기를 숨긴 채 세움(`setRouteShiftOpener` · 페이지가 닮 · 알림도 페이지 것)
|
||||
* ========================================================================== */
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** 판정 원 클래스 — 고르기 차례가 보라 선 위로 올림. */
|
||||
export const CENTER_HIT_CLASS = "b06-chart__center-hit";
|
||||
/** 판정 원 반지름(px). */
|
||||
const HIT_RADIUS_PX = 8;
|
||||
/** 좌우 걸음(m). */
|
||||
export const CENTER_STEP_M = 0.1;
|
||||
/** 키를 멈추고 이만큼 지나면 보냄(ms) — 잇단 누름 = 한 번. */
|
||||
const KEY_SETTLE_MS = 450;
|
||||
/** 편집기를 기다리는 한계(ms) — 종단 보기 · 노선 자료 읽기. */
|
||||
const HOST_WAIT_MS = 20000;
|
||||
|
||||
/** 측점 · 좌(+)우(−) m → 못 옮긴 까닭(null = 옮김). */
|
||||
export type RouteShiftHost = (chainageM: number, deltaM: number) => Promise<string | null>;
|
||||
|
||||
let host: RouteShiftHost | null = null;
|
||||
const waiters = new Set<(ready: RouteShiftHost | null) => void>();
|
||||
let opener: (() => Promise<void>) | null = null;
|
||||
type Notice = (text: string, kind: "info" | "error") => void;
|
||||
let notice: Notice = () => undefined;
|
||||
|
||||
/** 노선 편집기가 단다(떠나면 null). */
|
||||
export function setRouteShiftHost(next: RouteShiftHost | null): void {
|
||||
host = next;
|
||||
if (!next) return;
|
||||
waiters.forEach((resolve) => resolve(next));
|
||||
waiters.clear();
|
||||
}
|
||||
|
||||
/** 편집기를 세우는 길 · 알림 — 페이지가 단다(종단 보기 숨긴 채 세움 · 토스트). */
|
||||
export function setRouteShiftOpener(next: (() => Promise<void>) | null, toast?: Notice): void {
|
||||
opener = next;
|
||||
if (toast) notice = toast;
|
||||
}
|
||||
|
||||
async function readyHost(): Promise<RouteShiftHost | null> {
|
||||
if (host) return host;
|
||||
const waiting = new Promise<RouteShiftHost | null>((resolve) => {
|
||||
waiters.add(resolve);
|
||||
setTimeout(() => (waiters.delete(resolve), resolve(null)), HOST_WAIT_MS);
|
||||
});
|
||||
await opener?.().catch(() => undefined);
|
||||
return host ?? waiting;
|
||||
}
|
||||
|
||||
/** 그 측점 노선을 좌우로 — 편집기가 없으면 세워서. 옮겼으면 true. */
|
||||
export async function shiftRouteAt(chainageM: number, deltaM: number): Promise<boolean> {
|
||||
const ready = await readyHost();
|
||||
if (!ready) {
|
||||
notice("노선 편집기를 못 열어 좌우로 옮기지 못했습니다.", "error");
|
||||
return false;
|
||||
}
|
||||
const problem = await ready(chainageM, deltaM);
|
||||
if (problem) notice(problem, "info");
|
||||
return !problem;
|
||||
}
|
||||
|
||||
/** 판정 원(중심 십자 자리). */
|
||||
export function centerHit(cx: number, cy: number): SVGCircleElement {
|
||||
const hit = document.createElementNS(SVG_NS, "circle");
|
||||
hit.setAttribute("cx", String(cx));
|
||||
hit.setAttribute("cy", String(cy));
|
||||
hit.setAttribute("r", String(HIT_RADIUS_PX));
|
||||
hit.setAttribute("class", CENTER_HIT_CLASS);
|
||||
return hit;
|
||||
}
|
||||
|
||||
const snapStep = (valueM: number): number =>
|
||||
Math.round(Math.round(valueM / CENTER_STEP_M) * CENTER_STEP_M * 10) / 10;
|
||||
|
||||
export interface CenterShift {
|
||||
/** 화면 ◀(−1) · ▶(+1) 한 걸음 — 멈추면 모아 한 번 보냄. */
|
||||
step: (screenDir: -1 | 1) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌우 이동 배선 — `kx` = 화면 x / offset(m) 기울기 · `active` = 중심점을 골랐나 · `preview` = 미리 볼
|
||||
* 이동(m · 0 = 지움). 끌기는 고른 뒤에만(안 고른 원을 끌면 카드 팬 그대로).
|
||||
*/
|
||||
export function bindCenterShift(input: {
|
||||
hit: SVGElement;
|
||||
layer: SVGGraphicsElement;
|
||||
chainageM: number;
|
||||
kx: number;
|
||||
active: () => boolean;
|
||||
preview: (deltaM: number) => void;
|
||||
}): CenterShift {
|
||||
const { hit, layer, chainageM, kx, active } = input;
|
||||
const preview = (deltaM: number): void => {
|
||||
const shift = deltaM ? `${deltaM * kx}px 0px` : "";
|
||||
for (const el of [hit, layer.querySelector<SVGElement>("g.b06-chart__center")])
|
||||
el?.style.setProperty("translate", shift);
|
||||
input.preview(deltaM);
|
||||
};
|
||||
const send = (deltaM: number): void => {
|
||||
if (!deltaM) return preview(0);
|
||||
void shiftRouteAt(chainageM, deltaM).then((moved) => moved || preview(0));
|
||||
};
|
||||
hit.addEventListener("pointerdown", (event) => {
|
||||
const ctm = layer.getScreenCTM();
|
||||
if (event.button !== 0 || !active() || !ctm) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation(); // 카드 팬으로 안 번짐
|
||||
const startX = event.clientX;
|
||||
let deltaM = 0;
|
||||
const move = (next: PointerEvent): void => {
|
||||
deltaM = snapStep((next.clientX - startX) / (ctm.a * kx));
|
||||
preview(deltaM);
|
||||
};
|
||||
const up = (): void => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
window.removeEventListener("pointercancel", up);
|
||||
send(deltaM);
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
window.addEventListener("pointercancel", up);
|
||||
});
|
||||
let pending = 0;
|
||||
let timer = 0;
|
||||
return {
|
||||
step: (screenDir) => {
|
||||
pending = snapStep(pending + screenDir * Math.sign(kx) * CENTER_STEP_M);
|
||||
preview(pending);
|
||||
clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
const deltaM = pending;
|
||||
pending = 0;
|
||||
send(deltaM);
|
||||
}, KEY_SETTLE_MS);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 조정창 읽기 글 — 미리 볼 이동(m · 0 = 안내). */
|
||||
export function centerPreviewText(deltaM: number): string {
|
||||
if (!deltaM) return "▲▼ = 종단 계획고 · ◀▶ · 끌기 = 이 측점 노선 좌우";
|
||||
return `노선 ${deltaM > 0 ? "좌" : "우"} ${Math.abs(deltaM).toFixed(1)}m — 놓으면 반영`;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
dpadButton,
|
||||
makeRow,
|
||||
} from "./B06_Section_UI_Cross_Panel_Base";
|
||||
import { bindCenterShift, centerHit, centerPreviewText } from "./B06_Section_UI_Cross_Center";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const DESIGN_CLASS = "b06-chart__design-cross";
|
||||
@@ -111,8 +112,9 @@ export const pieceZone = (design: ZoneDesign, piece: DesignPiece) =>
|
||||
* 절토 · 성토측(구조물 트림 선 포함) = 그 쪽 바깥 가로(x · 노견째 · 구조물 한 벌). */
|
||||
export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): MoveAxis =>
|
||||
isBermPiece(design, piece) ? "depth" : pieceZone(design, piece).zone === "road" ? "y" : "x";
|
||||
/** x = 사면 가로 · y = 노면 높이(종단 계획고) · depth = 소단 높이(그 쪽 깊이 · 194-1). */
|
||||
export type MoveAxis = "x" | "y" | "depth";
|
||||
/** x = 사면 가로 · y = 노면 높이(종단 계획고) · depth = 소단 높이(그 쪽 깊이 · 194-1) ·
|
||||
* center = 중심점(▲▼ 종단 계획고 · ◀▶ 노선 좌우 · 356-3). */
|
||||
export type MoveAxis = "x" | "y" | "depth" | "center";
|
||||
|
||||
/** 그 쪽 소단 깊이(노면 끝 아래 m) — 옮긴 값 → 구간 값 → 기본 1.0(194-1). */
|
||||
export const bermDepthOf = (design: ZoneDesign, side: CrossSide): number =>
|
||||
@@ -345,7 +347,7 @@ function tellBermHost(chainageM: number | null): void {
|
||||
export function designLineMoves(
|
||||
section: Pick<CrossSection, "station_id" | "chainage_m" | "design">,
|
||||
onDesignChange?: (chainageM: number, change: CrossDesignChange) => void,
|
||||
): { stationId: string; shift?: ShoulderShift; berm?: BermDepthControl } {
|
||||
): { stationId: string; shift?: ShoulderShift; berm?: BermDepthControl; center?: number } {
|
||||
const design = section.design;
|
||||
if (!onDesignChange || !design) return { stationId: section.station_id };
|
||||
const send = (change: Partial<CrossDesignChange>) =>
|
||||
@@ -355,6 +357,7 @@ export function designLineMoves(
|
||||
});
|
||||
return {
|
||||
stationId: section.station_id,
|
||||
center: section.chainage_m, // 중심점 좌우 = 이 측점 노선(356-3)
|
||||
shift: {
|
||||
valueOf: (side) => design[`shoulder_extend_${side}_m`] ?? 0,
|
||||
set: (side, valueM) => send({ shoulder_extend: { side, valueM } }),
|
||||
@@ -395,6 +398,8 @@ export function attachDesignLinePick(input: {
|
||||
berm?: BermDepthControl;
|
||||
/** 있으면 고름을 조각 키로 기억(194-2 · `pickedLineAt`). */
|
||||
stationId?: string;
|
||||
/** 있으면 중심점 고름(끝 조각 · 키 `center`) — 그 측점 누가거리(356-3). */
|
||||
center?: number;
|
||||
}): DesignLinePick {
|
||||
const { layer, design, x, toDisplayY } = input;
|
||||
const x0 = x(0);
|
||||
@@ -417,25 +422,38 @@ export function attachDesignLinePick(input: {
|
||||
)
|
||||
.map((el) => pointsOf(el).map(toM));
|
||||
const pieces = visiblePieces(mergeStraightPieces(runs), occluders);
|
||||
if (input.stationId) keysByStation.set(input.stationId, pieceKeys(design, pieces));
|
||||
const edges = design.road_edges;
|
||||
const centerZ =
|
||||
input.center === undefined || !edges || edges.left.offset_m === edges.right.offset_m
|
||||
? null
|
||||
: edges.right.elevation_m -
|
||||
((edges.left.elevation_m - edges.right.elevation_m) * edges.right.offset_m) /
|
||||
(edges.left.offset_m - edges.right.offset_m);
|
||||
const keys = [...pieceKeys(design, pieces), ...(centerZ === null ? [] : ["center"])];
|
||||
if (input.stationId) keysByStation.set(input.stationId, keys);
|
||||
|
||||
// 판정 선은 구조물 판정면 아래에 꽂음 — 카드가 끝에 `stackPickTargets` 로 맨 위에 다시 세움(295-1).
|
||||
const firstStructureHit = kids.find((el) =>
|
||||
el.classList.contains("b06-chart__culvert-revet-hit"),
|
||||
);
|
||||
const hits = pieces.map((piece, index) => {
|
||||
const hits: SVGElement[] = pieces.map((piece) => {
|
||||
const hit = document.createElementNS(SVG_NS, "polyline");
|
||||
hit.setAttribute(
|
||||
"points",
|
||||
[piece.from, piece.to].map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "),
|
||||
);
|
||||
hit.setAttribute("class", HIT_CLASS);
|
||||
return hit;
|
||||
});
|
||||
// 중심점(356-3) = 끝 조각 · 판정 원은 고르기 차례가 보라 선 위로(`stackPickTargets`)
|
||||
const center = centerZ === null ? null : centerHit(x0, toDisplayY(centerZ));
|
||||
if (center) hits.push(center);
|
||||
hits.forEach((hit, index) => {
|
||||
hit.addEventListener("click", (event) => {
|
||||
event.stopPropagation(); // 카드 고름 · 면적 띠로 번지지 않음
|
||||
input.onPick(index);
|
||||
});
|
||||
layer.insertBefore(hit, firstStructureHit ?? null);
|
||||
return hit;
|
||||
});
|
||||
|
||||
const label = document.createElement("span");
|
||||
@@ -455,7 +473,19 @@ export function attachDesignLinePick(input: {
|
||||
readout.className = "b06-structure-panel__value";
|
||||
const move = makeRow("이동");
|
||||
move.controls.classList.add("b06-structure-panel__buttons");
|
||||
const centerShift =
|
||||
center && input.center !== undefined
|
||||
? bindCenterShift({
|
||||
hit: center,
|
||||
layer: layer as SVGGraphicsElement,
|
||||
chainageM: input.center,
|
||||
kx,
|
||||
active: () => axis === "center",
|
||||
preview: (deltaM) => (readout.textContent = centerPreviewText(deltaM)),
|
||||
})
|
||||
: null;
|
||||
const step = (dir: -1 | 1) => () => {
|
||||
if (axis === "center") return centerShift?.step(dir);
|
||||
if (side && shift) shift.set(side, shiftedExtend(shift.valueOf(side), side, dir, kx));
|
||||
};
|
||||
move.controls.append(
|
||||
@@ -521,36 +551,48 @@ export function attachDesignLinePick(input: {
|
||||
label,
|
||||
panel: shell.root,
|
||||
setActive: (index) => {
|
||||
const onCenter = !!centerShift && index === pieces.length;
|
||||
const piece = index === null ? undefined : pieces[index];
|
||||
hits.forEach((hit, i) => hit.classList.toggle("is-active", !!piece && i === index));
|
||||
label.hidden = !piece;
|
||||
active = piece ? index : null;
|
||||
hits.forEach((hit, i) =>
|
||||
hit.classList.toggle("is-active", (!!piece || onCenter) && i === index),
|
||||
);
|
||||
label.hidden = !piece && !onCenter;
|
||||
active = piece || onCenter ? index : null;
|
||||
side = piece ? pieceZone(design, piece).side : null;
|
||||
axis = piece ? pieceMoveAxis(design, piece) : "x";
|
||||
axis = onCenter ? "center" : piece ? pieceMoveAxis(design, piece) : "x";
|
||||
const extend = side && shift && axis === "x" ? shift.valueOf(side) : 0;
|
||||
label.textContent = piece
|
||||
? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "")
|
||||
: "";
|
||||
const open = !!(piece && (axis === "depth" ? berm : shift));
|
||||
label.textContent = onCenter
|
||||
? "중심점"
|
||||
: piece
|
||||
? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "")
|
||||
: "";
|
||||
const open = onCenter || !!(piece && (axis === "depth" ? berm : shift));
|
||||
shell.root.classList.toggle("is-hidden", !open);
|
||||
// 이동 셋 분리(194-2) — 사면 = 이동 줄(◀▶) · 소단 = 높이 줄(▲▼) · 노면 = 둘 다 숨김(▲▼ 종단)
|
||||
move.row.classList.toggle("is-hidden", axis !== "x");
|
||||
move.controls.classList.toggle("is-hidden", axis !== "x");
|
||||
// 중심점 = 이동 줄 ◀▶(노선 좌우 · ↺ 없음) · ▲▼ 는 종단도로(356-3)
|
||||
const sideways = axis === "x" || axis === "center";
|
||||
move.row.classList.toggle("is-hidden", !sideways);
|
||||
move.controls.classList.toggle("is-hidden", !sideways);
|
||||
move.controls.children[1]?.classList.toggle("is-hidden", axis === "center");
|
||||
lift.row.classList.toggle("is-hidden", axis !== "depth");
|
||||
lift.controls.classList.toggle("is-hidden", axis !== "depth");
|
||||
const sideName = side === "left" ? "(좌)" : "(우)";
|
||||
shell.title.textContent =
|
||||
axis === "y"
|
||||
? "노면 높이"
|
||||
: axis === "depth"
|
||||
? `소단 높이 ${sideName}`
|
||||
: `사면 이동 ${sideName}`;
|
||||
axis === "center"
|
||||
? "중심점"
|
||||
: axis === "y"
|
||||
? "노면 높이"
|
||||
: axis === "depth"
|
||||
? `소단 높이 ${sideName}`
|
||||
: `사면 이동 ${sideName}`;
|
||||
readout.textContent =
|
||||
axis === "y"
|
||||
? "방향키 ▲▼ = 종단 계획고(종단도 함께)"
|
||||
: axis === "depth" && side
|
||||
? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m`
|
||||
: `이동 ${extend.toFixed(1)}m`;
|
||||
axis === "center"
|
||||
? centerPreviewText(0)
|
||||
: axis === "y"
|
||||
? "방향키 ▲▼ = 종단 계획고(종단도 함께)"
|
||||
: axis === "depth" && side
|
||||
? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m`
|
||||
: `이동 ${extend.toFixed(1)}m`;
|
||||
berm?.focus(axis === "depth" && !!piece); // 좌측 소단 상자 켬 · 끔(194-2)
|
||||
arrows.detach();
|
||||
document.removeEventListener("keydown", blockOffAxis);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { thickenDown } from "./B06_Section_UI_Cross_Shape_Pick";
|
||||
import { CENTER_HIT_CLASS } from "./B06_Section_UI_Cross_Center";
|
||||
|
||||
/** 면 판정 — 구조물(벽 · 관 · 박스 · 도수로 띠 등) · 물넘이포장 · 맹암거 형상. */
|
||||
const SURFACE_HIT_CLASSES = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"];
|
||||
@@ -69,7 +70,8 @@ export function stackPickTargets(layer: SVGElement): void {
|
||||
.sort((a, b) => b.area - a.area)
|
||||
.map(({ el }) => el);
|
||||
const lines = kids.filter((el) => el.classList.contains(LINE_HIT_CLASS));
|
||||
layer.append(...surfaces, ...edges, ...lines);
|
||||
const centers = kids.filter((el) => el.classList.contains(CENTER_HIT_CLASS)); // 중심점 = 맨 위(356-3)
|
||||
layer.append(...surfaces, ...edges, ...lines, ...centers);
|
||||
let down: { x: number; y: number } | null = null;
|
||||
layer.addEventListener(
|
||||
"pointerdown",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* · 노면 = 설계 노면 — 서버 · TS 짝이 암거 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음.
|
||||
* · 옵셋선 = 노면을 암거 높이만큼 아래로 같은 각도.
|
||||
* · 성토 쪽 = 옵셋선을 성토 사면과 만날 때까지 · 절토 쪽 = 절토 사면을 아래로 늘여 옵셋선과 만남.
|
||||
* · 양측절토(낮은 쪽 늘인 노면 `tilt_reach`) = 늘인 노면이 원지반과 만나는 점에서 수직으로 끊음(356-1).
|
||||
* · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(306-1) — 켜마다 반 칸 엇갈리고 이웃과 겹친 돌 ·
|
||||
* 기울기를 번갈아 서로 교차 · 그림 전용(누름 안 받음 · CSS `.b06-chart__underdrain`).
|
||||
* 높이가 비면 그리지 않음(수치를 지어내지 않음).
|
||||
@@ -93,7 +94,12 @@ export function underdrainOutline(
|
||||
const span = left.offset_m - right.offset_m;
|
||||
if (!(Math.abs(span) > 1e-9)) return null;
|
||||
const grade = (left.elevation_m - right.elevation_m) / span;
|
||||
const line = design.design_line ?? [];
|
||||
// 양측절토(356-1) — 낮은 쪽 늘인 노면이 원지반과 만나는 점(tilt_reach)에서 끊음 · 너머 점 거름 → 꼬리가 수직으로 닫음
|
||||
const reach = design.tilt_reach;
|
||||
const outward = reach?.side === "left" ? 1 : -1;
|
||||
const line = (design.design_line ?? []).filter(
|
||||
(p) => !reach || (p.offset_m - reach.offset_m) * outward <= 1e-6,
|
||||
);
|
||||
const leftPath = sidePath(left, 1, line, grade, heightM);
|
||||
const rightPath = sidePath(right, -1, line, grade, heightM);
|
||||
return [
|
||||
|
||||
@@ -183,11 +183,8 @@ export function createCrossSectionCard(
|
||||
const card: CrossCardElement = document.createElement("article");
|
||||
card.id = `cross-${section.station_id}`;
|
||||
card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`;
|
||||
card.tabIndex = 0;
|
||||
// 초점 없음(356-2) — 마우스로 고른 뒤 키를 눌러도 초점 테두리 안 뜸 · ▲▼ 계획고는 창 듣개(219-1)
|
||||
card.addEventListener("click", () => onSelect(section.station_id));
|
||||
card.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") onSelect(section.station_id);
|
||||
});
|
||||
|
||||
// 선택 표시·면적 강조는 **DOM만 갈아 끼운다**. 카드를 새로 만들면 휠 줌·팬(viewBox)이
|
||||
// 초기화돼 사용자가 맞춰 둔 배율이 날아간다(2026-08-02 사용자 지적).
|
||||
|
||||
@@ -175,6 +175,24 @@
|
||||
stroke-width: 7;
|
||||
}
|
||||
|
||||
/* 중심점 고름(356-3) — 십자 자리 투명 원 · 고르면 보라 테두리 · 끌면 좌우. 십자는 누름을 원으로 넘김. */
|
||||
.b06-chart__center {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b06-chart__center-hit {
|
||||
fill: transparent;
|
||||
stroke: transparent;
|
||||
stroke-width: 2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b06-chart__center-hit.is-active {
|
||||
fill: color-mix(in srgb, var(--color-royal-amethyst) 18%, transparent);
|
||||
stroke: var(--color-royal-amethyst);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
/* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */
|
||||
.b06-chart__carriageway-tick {
|
||||
stroke: var(--color-royal-amethyst);
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
`_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m.
|
||||
|
||||
⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · side_is_cut · slope_end ·
|
||||
slope_pieces) — 종단 · 횡단 편집 중 평면 발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림.
|
||||
⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · route_normal · side_is_cut ·
|
||||
slope_end · slope_pieces 와 그 아래 보간 · 촘촘히 · 접힘 버림) — 종단 · 횡단 편집 중 평면
|
||||
발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림.
|
||||
거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py`
|
||||
"""
|
||||
|
||||
import math
|
||||
from bisect import bisect_left
|
||||
from bisect import bisect_left, bisect_right
|
||||
from typing import Any
|
||||
|
||||
from config.config_system import DRAWING_SCALE_PLAN
|
||||
@@ -39,19 +40,6 @@ def route_frame(
|
||||
return (x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length)
|
||||
|
||||
|
||||
def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float:
|
||||
chainages = [record["chainage_m"] for record in records]
|
||||
index = bisect_left(chainages, chainage)
|
||||
if index <= 0:
|
||||
return records[0][key]
|
||||
if index >= len(records):
|
||||
return records[-1][key]
|
||||
before, after = records[index - 1], records[index]
|
||||
span = after["chainage_m"] - before["chainage_m"]
|
||||
ratio = (chainage - before["chainage_m"]) / span if span > 0 else 0.0
|
||||
return before[key] + (after[key] - before[key]) * ratio
|
||||
|
||||
|
||||
def _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]:
|
||||
return sorted(
|
||||
(float(item["offset_m"]), float(item["elevation_m"]))
|
||||
@@ -120,6 +108,149 @@ def _split_runs(
|
||||
)
|
||||
|
||||
|
||||
#: 절/성토선 매끄럽게(353-1) — 네 선 중 하나라도 사이 가운데 점에서 이보다 꺾이면 반으로 나눔(°)
|
||||
SPLIT_TURN_DEG = 2.0
|
||||
#: 나눔 깊이 상한(2 m → 12.5 cm)
|
||||
SPLIT_DEPTH = 4
|
||||
#: 사이 점 법선을 재는 앞뒤 거리(m) — 측점 횡단 틀(`_tangent_at`)과 같은 값
|
||||
NORMAL_PROBE_M = 1.0
|
||||
|
||||
|
||||
def route_normal(
|
||||
route: list[tuple[float, float, float]], chainage: float
|
||||
) -> tuple[float, float, float, float]:
|
||||
"""노선 위 그 자리와 좌측 법선 — 법선은 앞뒤 ±1 m 두 점을 잇는 방향(측점 횡단 틀과 같은 식)."""
|
||||
x, y, nx, ny = route_frame(route, chainage)
|
||||
first, last = route[0][0], route[-1][0]
|
||||
before, after = max(first, chainage - NORMAL_PROBE_M), min(last, chainage + NORMAL_PROBE_M)
|
||||
if after - before <= 1e-9:
|
||||
before, after = max(first, chainage - 1e-3), min(last, chainage + 1e-3)
|
||||
x0, y0, _nx, _ny = route_frame(route, before)
|
||||
x1, y1, _nx, _ny = route_frame(route, after)
|
||||
length = math.hypot(x1 - x0, y1 - y0)
|
||||
if length <= 1e-9:
|
||||
return x, y, nx, ny
|
||||
return x, y, -(y1 - y0) / length, (x1 - x0) / length
|
||||
|
||||
|
||||
def _pchip_slopes(xs: list[float], ys: list[float]) -> list[float]:
|
||||
"""단조 3차(Fritsch–Carlson) 꺾임점 기울기 — 이웃 값 사이를 넘지 않는다."""
|
||||
n = len(xs)
|
||||
if n < 2:
|
||||
return [0.0] * n
|
||||
h = [xs[i + 1] - xs[i] for i in range(n - 1)]
|
||||
delta = [(ys[i + 1] - ys[i]) / h[i] for i in range(n - 1)]
|
||||
slopes = [delta[0]] + [0.0] * (n - 2) + [delta[-1]]
|
||||
for k in range(1, n - 1):
|
||||
if delta[k - 1] * delta[k] > 0:
|
||||
w1, w2 = 2 * h[k] + h[k - 1], h[k] + 2 * h[k - 1]
|
||||
slopes[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k])
|
||||
return slopes
|
||||
|
||||
|
||||
def _pchip(knots: tuple[list[float], list[float], list[float]], x: float) -> float:
|
||||
"""꺾임점 값을 그대로 지나는 단조 3차 보간 — 범위 밖은 끝값."""
|
||||
xs, ys, slopes = knots
|
||||
if x <= xs[0]:
|
||||
return ys[0]
|
||||
if x >= xs[-1]:
|
||||
return ys[-1]
|
||||
i = bisect_right(xs, x) - 1
|
||||
h = xs[i + 1] - xs[i]
|
||||
t = (x - xs[i]) / h
|
||||
return (
|
||||
(1 + 2 * t) * (1 - t) ** 2 * ys[i]
|
||||
+ t * (1 - t) ** 2 * h * slopes[i]
|
||||
+ t * t * (3 - 2 * t) * ys[i + 1]
|
||||
+ t * t * (t - 1) * h * slopes[i + 1]
|
||||
)
|
||||
|
||||
|
||||
def _knots(xs: list[float], ys: list[float]) -> tuple[list[float], list[float], list[float]]:
|
||||
return xs, ys, _pchip_slopes(xs, ys)
|
||||
|
||||
|
||||
def _side_curves(records: list[dict[str, Any]], side: str) -> dict[str, Any]:
|
||||
"""한 쪽 도로 끝 · 비탈 폭 보간 + 깎기↔쌓기 바뀜 자리(폭 0 = 비탈 끝이 도로 끝으로 모임)."""
|
||||
chainages = [record["chainage_m"] for record in records]
|
||||
edges = [record[f"{side}_edge_m"] for record in records]
|
||||
widths = [record[f"{side}_end_m"] - record[f"{side}_edge_m"] for record in records]
|
||||
flags = [bool(record[f"{side}_cut"]) for record in records]
|
||||
switches: list[float] = []
|
||||
width_x, width_y = [chainages[0]], [widths[0]]
|
||||
for i in range(1, len(records)):
|
||||
c0, c1 = chainages[i - 1], chainages[i]
|
||||
w0, w1 = abs(widths[i - 1]), abs(widths[i])
|
||||
switch = c0 + (c1 - c0) * (w0 / (w0 + w1) if w0 + w1 > 1e-9 else 0.5)
|
||||
switches.append(switch)
|
||||
if flags[i - 1] != flags[i] and c0 + 1e-9 < switch < c1 - 1e-9:
|
||||
width_x.append(switch)
|
||||
width_y.append(0.0)
|
||||
width_x.append(c1)
|
||||
width_y.append(widths[i])
|
||||
return {
|
||||
"chainages": chainages,
|
||||
"flags": flags,
|
||||
"switches": switches,
|
||||
"edge": _knots(chainages, edges),
|
||||
"width": _knots(width_x, width_y),
|
||||
}
|
||||
|
||||
|
||||
def _cut_at(curves: dict[str, Any], chainage: float) -> bool:
|
||||
"""그 자리 깎기 여부 — 바뀜 자리부터 뒤 측점 것(바뀌는 점은 앞뒤 조각이 나눠 가짐)."""
|
||||
flags = curves["flags"]
|
||||
i = bisect_right(curves["chainages"], chainage)
|
||||
if i == 0:
|
||||
return flags[0]
|
||||
if i == len(flags):
|
||||
return flags[-1]
|
||||
if flags[i - 1] == flags[i]:
|
||||
return flags[i]
|
||||
return flags[i - 1] if chainage < curves["switches"][i - 1] else flags[i]
|
||||
|
||||
|
||||
def _forward_only(points: list[list[float]], frames: list[tuple[float, ...]]) -> list[int]:
|
||||
"""진행을 거스르는 점(안쪽 비탈 끝이 곡선 중심을 넘어 접힘)을 버린 남는 점 번호."""
|
||||
kept = [0] if points else []
|
||||
for i in range(1, len(points)):
|
||||
last = points[kept[-1]]
|
||||
_x, _y, nx, ny = frames[i]
|
||||
if (points[i][0] - last[0]) * ny - (points[i][1] - last[1]) * nx > 1e-6:
|
||||
kept.append(i)
|
||||
return kept
|
||||
|
||||
|
||||
def _turn_deg(a: list[float], m: list[float], b: list[float]) -> float:
|
||||
u, v = (m[0] - a[0], m[1] - a[1]), (b[0] - m[0], b[1] - m[1])
|
||||
return abs(math.degrees(math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1])))
|
||||
|
||||
|
||||
def _densify(
|
||||
chainages: list[float], frame_of: Any, place: Any
|
||||
) -> tuple[list[float], list[tuple[float, float, float, float]]]:
|
||||
"""곡선 · 폭이 가파르게 바뀌는 자리 — 가운데 점에서 많이 꺾이는 사이를 반씩 나눔."""
|
||||
|
||||
def split(c0: float, p0: list, c1: float, p1: list, depth: int) -> list[float]:
|
||||
if depth >= SPLIT_DEPTH:
|
||||
return []
|
||||
middle = (c0 + c1) / 2.0
|
||||
pm = place(middle, frame_of(middle))
|
||||
# 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음
|
||||
if not any(SPLIT_TURN_DEG < _turn_deg(a, m, b) < 90.0 for a, m, b in zip(p0, pm, p1)):
|
||||
return []
|
||||
return (
|
||||
split(c0, p0, middle, pm, depth + 1) + [middle] + split(middle, pm, c1, p1, depth + 1)
|
||||
)
|
||||
|
||||
points = [place(chainage, frame_of(chainage)) for chainage in chainages]
|
||||
out = chainages[:1]
|
||||
for i in range(1, len(chainages)):
|
||||
out += split(chainages[i - 1], points[i - 1], chainages[i], points[i], 0)
|
||||
out.append(chainages[i])
|
||||
return out, [frame_of(chainage) for chainage in out]
|
||||
|
||||
|
||||
def slope_pieces(
|
||||
route: list[tuple[float, float, float]],
|
||||
records: list[dict[str, Any]],
|
||||
@@ -131,60 +262,94 @@ def slope_pieces(
|
||||
|
||||
`records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m,
|
||||
left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의
|
||||
중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을
|
||||
따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다.
|
||||
중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 단조 3차
|
||||
보간(측점 값 그대로 지남) · 법선은 측점과 같은 ±1 m 식 · 곡선은 촘촘히 · 진행을 거스르는
|
||||
점은 버림 · 깎기↔쌓기 바뀜 자리에서 비탈 끝이 도로 끝으로 모인다(353-1).
|
||||
비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다.
|
||||
"""
|
||||
records = sorted(
|
||||
sorted_records = sorted(
|
||||
(
|
||||
{
|
||||
**record,
|
||||
**{f"{s}_cut": float(bool(record.get(f"{s}_cut"))) for s in ("left", "right")},
|
||||
}
|
||||
{**record, **{f"{s}_cut": bool(record.get(f"{s}_cut")) for s in ("left", "right")}}
|
||||
for record in records
|
||||
),
|
||||
key=lambda record: record["chainage_m"],
|
||||
)
|
||||
if len(route) < 2 or not records:
|
||||
if len(route) < 2 or not sorted_records:
|
||||
return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []}
|
||||
# 같은 누가거리 측점은 뒤 것 하나만
|
||||
records = []
|
||||
for record in sorted_records:
|
||||
if records and record["chainage_m"] - records[-1]["chainage_m"] <= 1e-9:
|
||||
records[-1] = record
|
||||
else:
|
||||
records.append(record)
|
||||
sides = ("left", "right")
|
||||
keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m")
|
||||
curves = {side: _side_curves(records, side) for side in sides}
|
||||
by_chainage = {round(record["chainage_m"], 3): record for record in records}
|
||||
samples = {
|
||||
base = {
|
||||
round(record["chainage_m"], 3)
|
||||
for record in records
|
||||
if start_m <= record["chainage_m"] <= end_m
|
||||
}
|
||||
step = start_m
|
||||
while step <= end_m:
|
||||
samples.add(round(step, 3))
|
||||
base.add(round(step, 3))
|
||||
step += step_m
|
||||
lines: dict[str, list[list[float]]] = {key: [] for key in keys}
|
||||
cut: dict[str, list[bool]] = {"left": [], "right": []}
|
||||
ticks: list[list[list[float]]] = []
|
||||
for index, chainage in enumerate(sorted(samples)):
|
||||
record = by_chainage.get(chainage)
|
||||
switches = {
|
||||
at
|
||||
for side in sides
|
||||
for i, at in enumerate(curves[side]["switches"])
|
||||
if curves[side]["flags"][i] != curves[side]["flags"][i + 1] and start_m <= at <= end_m
|
||||
}
|
||||
|
||||
def frame_of(chainage: float) -> tuple[float, float, float, float]:
|
||||
record = by_chainage.get(round(chainage, 3))
|
||||
if record and all(k in record for k in ("x", "y", "nx", "ny")):
|
||||
x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"]
|
||||
else:
|
||||
x, y, nx, ny = route_frame(route, chainage)
|
||||
offsets = {key: _interp(records, chainage, key) for key in keys}
|
||||
points = {
|
||||
key: [round(x + nx * value, 4), round(y + ny * value, 4)]
|
||||
for key, value in offsets.items()
|
||||
}
|
||||
return record["x"], record["y"], record["nx"], record["ny"]
|
||||
return route_normal(route, chainage)
|
||||
|
||||
def values_at(chainage: float) -> dict[str, float]:
|
||||
values: dict[str, float] = {}
|
||||
for side in sides:
|
||||
edge = _pchip(curves[side]["edge"], chainage)
|
||||
values[f"{side}_edge_m"] = edge
|
||||
values[f"{side}_end_m"] = edge + _pchip(curves[side]["width"], chainage)
|
||||
return values
|
||||
|
||||
def place(chainage: float, frame: tuple[float, float, float, float]) -> list[list[float]]:
|
||||
x, y, nx, ny = frame
|
||||
values = values_at(chainage)
|
||||
return [[x + nx * values[key], y + ny * values[key]] for key in keys]
|
||||
|
||||
chainages, frames = _densify(sorted(base | switches), frame_of, place)
|
||||
offsets = [values_at(chainage) for chainage in chainages]
|
||||
raw: dict[str, list[list[float]]] = {key: [] for key in keys}
|
||||
for (x, y, nx, ny), values in zip(frames, offsets):
|
||||
for key in keys:
|
||||
lines[key].append(points[key])
|
||||
for side in cut:
|
||||
cut[side].append(_interp(records, chainage, f"{side}_cut") > 0.5)
|
||||
# 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례).
|
||||
for side in ("left", "right"):
|
||||
edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"]
|
||||
if abs(end - edge) < 0.05:
|
||||
raw[key].append([round(x + nx * values[key], 4), round(y + ny * values[key], 4)])
|
||||
kept = {key: _forward_only(raw[key], frames) for key in keys}
|
||||
lines = {key: [raw[key][i] for i in kept[key]] for key in keys}
|
||||
cut = {
|
||||
side: [_cut_at(curves[side], chainages[i]) for i in kept[f"{side}_end_m"]] for side in sides
|
||||
}
|
||||
# 눈금은 노견 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아
|
||||
# 선다(납품 도면 관례) · 접혀 버린 자리는 뺌.
|
||||
ticks: list[list[list[float]]] = []
|
||||
kept_sets = {key: set(kept[key]) for key in keys}
|
||||
position = {chainage: i for i, chainage in enumerate(chainages)}
|
||||
for index, chainage in enumerate(sorted(base)):
|
||||
i = position[chainage]
|
||||
x, y, nx, ny = frames[i]
|
||||
for side in sides:
|
||||
edge, end = offsets[i][f"{side}_edge_m"], offsets[i][f"{side}_end_m"]
|
||||
if abs(end - edge) < 0.05 or i not in kept_sets[f"{side}_end_m"]:
|
||||
continue
|
||||
reach = end if index % 2 == 0 else edge + (end - edge) / 2.0
|
||||
ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]])
|
||||
ticks.append([raw[f"{side}_edge_m"][i], [x + nx * reach, y + ny * reach]])
|
||||
cut_lines: list[list[list[float]]] = []
|
||||
fill_lines: list[list[list[float]]] = []
|
||||
for side in cut:
|
||||
for side in sides:
|
||||
cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side])
|
||||
cut_lines += cut_runs
|
||||
fill_lines += fill_runs
|
||||
|
||||
@@ -43,7 +43,7 @@ export const VIEW_COMMANDS: CadCommand[] = [
|
||||
label: '범위 줌',
|
||||
aliases: ['Z'],
|
||||
glyph: '⛶',
|
||||
hint: '범위(E) · 전체(A) · 이전(P) · 윈도우(W) · 2X — 오토캐드 ZOOM(⛶ 단추 = 바로 범위)',
|
||||
hint: '범위(E) · 전체(A) · 이전(P) · 윈도우(W) · 2X — 오토캐드 ZOOM(⛶ 버튼 = 바로 범위)',
|
||||
run: () => {
|
||||
startZoomPrompt(); // 덧물음 — 명령 중 'Z 도 같은 길
|
||||
return '';
|
||||
|
||||
@@ -144,7 +144,7 @@ export function openRowPopup(o: PopupOpen): void {
|
||||
title.className = "b09-popup__title";
|
||||
title.textContent = tabName(tab);
|
||||
const goButton = createButton({
|
||||
label: "탭으로",
|
||||
label: "탭 이동",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
|
||||
@@ -59,7 +59,7 @@ export function openQuotes(key: string, label: string, onChange: (names: string[
|
||||
onClick: () => void download(key, f.name).catch(fail),
|
||||
}),
|
||||
createButton({
|
||||
label: "지우기",
|
||||
label: "삭제",
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
|
||||
@@ -28,7 +28,7 @@ const ACTS: [string, string][] = [
|
||||
["↓ 아래로", "아래로 ↓"],
|
||||
];
|
||||
|
||||
const GUIDE = "줄 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 자리 · 줄 끌기 · 더블클릭 · Enter · F2 입력";
|
||||
const GUIDE = "줄 누르고 버튼 · 방향키 ←→ 레벨 · ↑↓ 자리 · 줄 끌기 · 더블클릭 · Enter · F2 입력";
|
||||
|
||||
let picked: string | null = null;
|
||||
/** 242-1 이동해 온 범위 [첫 줄, 끝 줄] — 고름과 한때 하나 */
|
||||
|
||||
@@ -178,7 +178,7 @@ export function renderB09Print(root: HTMLElement): MountedPage {
|
||||
};
|
||||
|
||||
const pdfBtn = createButton({
|
||||
label: "PDF 내려받기",
|
||||
label: "PDF 다운로드",
|
||||
onClick: () => {
|
||||
if (!shown) return;
|
||||
const name = `${label()}.pdf`;
|
||||
@@ -190,7 +190,7 @@ export function renderB09Print(root: HTMLElement): MountedPage {
|
||||
});
|
||||
pdfBtn.dataset.b09PrintPdf = "";
|
||||
const xlsxBtn = createButton({
|
||||
label: "엑셀 내려받기",
|
||||
label: "엑셀 다운로드",
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
const ids = picked();
|
||||
@@ -205,7 +205,7 @@ export function renderB09Print(root: HTMLElement): MountedPage {
|
||||
});
|
||||
xlsxBtn.dataset.b09PrintXlsx = "";
|
||||
const back = createButton({
|
||||
label: "B09 로 돌아가기",
|
||||
label: "B09 복귀",
|
||||
variant: "ghost",
|
||||
onClick: () => navigateTo(ROUTES.B09_ESTIMATION),
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ const TEXT = {
|
||||
List_New: ["새것", "New"],
|
||||
List_Deleted: ["지움", "Deleted"],
|
||||
Bar_Back: ["← 로직 목록", "← Logics"],
|
||||
Bar_Delete: ["로직 지우기", "Delete logic"],
|
||||
Bar_Delete: ["로직 삭제", "Delete logic"],
|
||||
Bar_Save: ["저장", "Save"],
|
||||
Bar_Discard: ["캐시 삭제", "Clear cache"],
|
||||
Bar_Pending: ["저장 안 한 로직 {n} 개", "{n} unsaved logics"],
|
||||
@@ -57,7 +57,7 @@ const TEXT = {
|
||||
Rounding: ["끝수", "Rounding"],
|
||||
Formula: ["식", "Formula"],
|
||||
AddRow: ["줄 추가", "Add line"],
|
||||
DeleteRow: ["줄 지우기", "Delete line"],
|
||||
DeleteRow: ["줄 삭제", "Delete line"],
|
||||
Calc_Title: ["시험 계산", "Test calculation"],
|
||||
Calc_Run: ["계산", "Calculate"],
|
||||
Calc_NoInputs: ["받을 값 없음", "No inputs"],
|
||||
|
||||
@@ -60,7 +60,7 @@ export const TEXT = {
|
||||
],
|
||||
LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"],
|
||||
Hint: [
|
||||
"상자 머리를 끌어 옮김 · 가운데 단추(또는 Space + 끌기)로 판 옮김 · 휠로 확대",
|
||||
"상자 머리를 끌어 옮김 · 가운데 버튼(또는 Space + 끌기)로 판 옮김 · 휠로 확대",
|
||||
"Drag a head to move · middle button (or Space + drag) to pan · wheel to zoom",
|
||||
],
|
||||
Break: ["끊긴 곳", "Break"],
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 「실무 0.14㎥ × 1 · 규격」, 입력값,
|
||||
수량 {손 = 엑셀 수량 × 배}} · 한 줄 로직 여럿 = 같은 별칭 · 같은 로직 둘째부터 = 한 줄
|
||||
사전조합으로 감싸 참조(같은 로직 두 번 금지) ·
|
||||
자재 = {코드, 수량 {손}, 별도계상}(같은 자재 = 수량 더함) · 호표 = 확인된 사전조합 참조.
|
||||
자재 = {코드, 수량 {손}, 세 칸}(같은 자재 = 수량 더함 · 세 칸 = `Material.marks`
|
||||
별도계상 · 자재구분 · 운반비 · 358-2) · 로직 자재와 같은 품목 = 사용자 자재에서 빼고
|
||||
세 칸만 그 로직 `자재표시` 로(빠진 줄에 까닭) · 호표 = 확인된 사전조합 참조.
|
||||
같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음
|
||||
(`M02_Hopyo_Ledger` · 호표 참조 줄 뺌).
|
||||
빠진 줄(확인 안 함 · 뺌 · %) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가(참고).
|
||||
@@ -29,6 +31,8 @@ from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
||||
from M02_MasterTemplete import M02_Hopyo_Material as hmat
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
@@ -39,6 +43,7 @@ LINES_MAX = 500
|
||||
UNCHECKED = "확인 안 함"
|
||||
PERCENT = "% 줄 — 우리 로직 덧줄이 셈"
|
||||
WHOLE = "호표 통째로 대신"
|
||||
TWICE = "로직 자재와 같은 품목 — 세 칸만 로직 자재표시로(수량 = 로직 셈)"
|
||||
|
||||
|
||||
def mark(key: str, no: int, at: int | None = None) -> str:
|
||||
@@ -131,6 +136,7 @@ class Builder:
|
||||
self.source = refs.where(source)
|
||||
self.done = confirmed(folder, self.source)
|
||||
self.known = ledger._known()
|
||||
self.mat_names = {x["자재"]: x["이름"] for x in hopyo_match.targets()["자재"]}
|
||||
self.hopyo = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == self.no), None)
|
||||
if self.hopyo is None:
|
||||
raise _bad(f"없는 호표 「제{no}호표」", 404)
|
||||
@@ -197,20 +203,26 @@ class Builder:
|
||||
def build(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> dict:
|
||||
stamp = datetime.now().isoformat(timespec="seconds")
|
||||
hopyo, file = self.hopyo, self.doc.get("파일") or ""
|
||||
lines, mats, missing, used = [], {}, [], set()
|
||||
lines, mats, missing, used, mat_lines = [], {}, [], set(), {}
|
||||
|
||||
def put(line: dict[str, Any], one: dict[str, Any], alias: str | None) -> None:
|
||||
qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"]))
|
||||
memo = _memo(line, one)
|
||||
if "자재" in one:
|
||||
apart = "별도계상" if line.get("갈래") == "별도계상" else "직접계산"
|
||||
have = mats.get(one["자재"])
|
||||
hand = qty + Decimal(str(have["수량"]["손"])) if have else qty
|
||||
name = self.mat_names.get(one["자재"], "")
|
||||
mats[one["자재"]] = {
|
||||
"코드": one["자재"],
|
||||
"수량": {"손": _num(hand)},
|
||||
"별도계상": apart,
|
||||
**(
|
||||
have
|
||||
and {k: have[k] for k in labor.MARKS if k in have}
|
||||
or hmat.marks(line, name)
|
||||
),
|
||||
}
|
||||
if not have:
|
||||
mat_lines[one["자재"]] = line
|
||||
return
|
||||
if "호표" in one:
|
||||
ref = {
|
||||
@@ -261,6 +273,8 @@ class Builder:
|
||||
pairs = [p for p in got["대상"] if "호표" not in p]
|
||||
if pairs:
|
||||
book[ledger.key(line)] = self._row(line, "짝", pairs)
|
||||
for code in self._inside(lines, mats): # 로직 자재와 같은 품목 = 세 칸만 로직 자재표시로
|
||||
missing.append(_missing(mat_lines[code], TWICE))
|
||||
name = " ".join(x for x in (hopyo["이름"], hopyo.get("규격")) if x)
|
||||
doc = {
|
||||
"이름": name,
|
||||
@@ -275,6 +289,22 @@ class Builder:
|
||||
wrote = ledger.write(self.folder, book, self.who, file or None)
|
||||
return {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}}
|
||||
|
||||
def _inside(self, lines: list[dict[str, Any]], mats: dict[str, dict[str, Any]]) -> list[str]:
|
||||
"""로직 자재와 같은 품목(Labor_Doc `no_twice` 400 막음) — 사용자 자재에서 빼고
|
||||
세 칸을 그 로직 줄 `자재표시 {재료 줄 차례: 세 칸}` 으로(수량 = 로직 셈) → 옮긴 코드."""
|
||||
whole, moved = m01.loaded()[1], []
|
||||
for row in (r for r in lines if r.get("로직")):
|
||||
got = labor.run_line(whole, row["로직"], row.get("입력값"), None, None)
|
||||
for m in got.get("재료") or []:
|
||||
code = (m.get("품목") or {}).get("키")
|
||||
if code in mats and code not in moved:
|
||||
marks = {k: mats[code][k] for k in labor.MARKS if k in mats[code]}
|
||||
row.setdefault("자재표시", {})[str(m["차례"])] = marks
|
||||
moved.append(code)
|
||||
for code in moved:
|
||||
mats.pop(code)
|
||||
return moved
|
||||
|
||||
def _row(self, line: dict[str, Any], kind: str, pairs: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""장부 한 줄 — 엑셀조항 = 줄 근거 출처(있으면)."""
|
||||
origin = {"품명": line.get("이름"), "규격": line.get("규격"), "단위": line.get("단위")}
|
||||
|
||||
@@ -151,6 +151,8 @@ def kind_of(line: dict[str, Any]) -> str:
|
||||
return "%"
|
||||
if ref.get("종류") == "일위":
|
||||
return "호표"
|
||||
if price.get("재료비") and not price.get("노무비") and not price.get("경비"):
|
||||
return "자재" # 재료비만 = 자재(「단산N참조」여도 · 358-2)
|
||||
if ref.get("종류") == "단산":
|
||||
return "단가산출"
|
||||
if unit in _MACHINE_UNITS:
|
||||
@@ -159,8 +161,6 @@ def kind_of(line: dict[str, Any]) -> str:
|
||||
return "노임"
|
||||
if not any(price.get(k) for k in ("계", *COSTS)):
|
||||
return "별도계상"
|
||||
if price.get("재료비") and not price.get("노무비") and not price.get("경비"):
|
||||
return "자재"
|
||||
return "기타"
|
||||
|
||||
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
|
||||
상태 차례 = ① % ② 호표 참조(만들 층에 확인된 사전조합이면 체크) ③ 노임(그릇 없음) ④ 장부 「우리에
|
||||
없음 ·
|
||||
안 씀」 → 뺌 ⑤ 장부 짝 → 별칭(근거 다름 · 숫자 판정 맞음이 다른 로직이면 체크 안 함) ⑥
|
||||
안 씀」 → 뺌 ⑤ 장부 짝 → 별칭(근거 다름이면 체크 안 함 · 숫자 판정 맞음이 다른 로직이면
|
||||
⑥ 판정 먼저 + 꼬리표 「장부와 다름」 · 짝은 후보 끝 덤 · 358-3) ⑥
|
||||
맞춤셈
|
||||
판정(맞음 = 체크 · 다른 점 · 후보) ⑦ 셈 전 · 숫자 없음 → 이름 · 근거 후보(「셈 중」 · 「숫자 판정
|
||||
없음」).
|
||||
장부 짝이 규격 다름(품명|단위 느슨 열쇠)이면 ⑤ 아님 — 꼬리표 「별칭 · 규격 다름」 · 짝은 후보
|
||||
끝 덤만.
|
||||
자재 · 별도계상 = 이름 · 규격 같음 하나면 맞음(체크) · 그 밖 후보.
|
||||
자재 · 별도계상 = 이름 · 규격 같음 하나 · 바탕 이름 · 규격 수 같은 한 품목이면 맞음(358-2).
|
||||
로직 줄 후보 묶음 표시(358-1) = 이름후보(옛 324 다섯 `Match.name_cands`) · 후보마다
|
||||
뜻겹침 · 출처같음 · 책 — 차례 · 무게 · 상태는 그대로.
|
||||
입력값 출처(칸마다) = 별칭 > 블록(옵션 찾기 값이 규격 읽기와 다름) > 규격(`spec_inputs`) > 자동값.
|
||||
체크 = 처음 제안일 뿐 — 들어감은 [확인하고 사전조합 만들기](다 창)만. 저장 없음 · 셈은 서버 단독.
|
||||
장부 = `M02_Hopyo_Ledger`(다 창) · 확인된 사전조합 = `M02_Hopyo_Build.confirmed`(다 창).
|
||||
@@ -27,6 +30,7 @@ from M02_MasterTemplete import M02_Hopyo_Finger as finger
|
||||
from M02_MasterTemplete import M02_Hopyo_Fit as fit
|
||||
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||||
from M02_MasterTemplete import M02_Hopyo_Material as hmat
|
||||
from M02_MasterTemplete import M02_Hopyo_Option as option
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
@@ -35,6 +39,9 @@ from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
SKIP = ("우리에 없음", "안 씀")
|
||||
LOOSE = "별칭 · 규격 다름"
|
||||
LEDGER_OFF = "장부와 다름"
|
||||
OUTSIDE = ("%", "노임", "뺌") # 연결 셈 밖(358-3 ②)
|
||||
|
||||
|
||||
class Ctx:
|
||||
@@ -115,6 +122,26 @@ def _qty(entry: dict, found: dict | None) -> dict:
|
||||
return {"엑셀": ex, "우리": us, "같음": same}
|
||||
|
||||
|
||||
def _picked(entry: dict) -> list[dict]:
|
||||
"""체크할 조합 — 로직이 하나뿐이면 조합 아님(기계 몫은 꼬리표)."""
|
||||
combo = [c for c in entry.get("조합") or [] if c["체크"]]
|
||||
return combo if len(combo) > 1 else []
|
||||
|
||||
|
||||
def _check(entry: dict, line: dict, ctx: Ctx) -> bool:
|
||||
"""체크 제안 한 규칙(358-3 · 목록 수 = 확인 창) — 판정 맞음 · 대상(조합 또는 1위) 있음 ·
|
||||
대상마다 환산 배 있음(추정 아님)."""
|
||||
codes = [c["로직"] for c in _picked(entry)] or (entry.get("차례") or [])[:1]
|
||||
if entry["판정"]["상태"] != "맞음" or not codes:
|
||||
return False
|
||||
for code in codes:
|
||||
unit = (ctx.names.get(code) or {}).get("단위", "")
|
||||
conv = hm.factor(line.get("단위"), unit, line.get("규격"))
|
||||
if conv["배"] is None or conv["갈래"] == "추정":
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _numeric(line: dict, ctx: Ctx, entry: dict) -> dict:
|
||||
"""⑥ 맞춤셈 판정 → 상태 · 대상 · 후보 · 품 · 글 · 꼬리표."""
|
||||
judged = entry["판정"]
|
||||
@@ -122,8 +149,7 @@ def _numeric(line: dict, ctx: Ctx, entry: dict) -> dict:
|
||||
top = (entry.get("차례") or [None])[0]
|
||||
found = (entry.get("옵션") or {}).get(top)
|
||||
use = state in ("맞음", "다른 점")
|
||||
combo = [c for c in entry.get("조합") or [] if c["체크"]]
|
||||
combo = combo if len(combo) > 1 else [] # 체크할 로직이 하나뿐이면 조합 아님(기계 몫은 꼬리표)
|
||||
combo = _picked(entry)
|
||||
if combo:
|
||||
targets = [
|
||||
_target(
|
||||
@@ -144,8 +170,7 @@ def _numeric(line: dict, ctx: Ctx, entry: dict) -> dict:
|
||||
if any("블록" in t["출처"].values() for t in targets):
|
||||
tags.append("옵션 — 블록에서")
|
||||
tags += [f"근거 {b} {n}" for b, n in (entry.get("지문") or {}).get("출처") or []]
|
||||
ok = all(t["배"] is not None and t["환산"]["갈래"] != "추정" for t in targets)
|
||||
out = {"상태": state, "체크": state == "맞음" and bool(targets) and ok, "대상": targets}
|
||||
out = {"상태": state, "체크": _check(entry, line, ctx), "대상": targets}
|
||||
out.update(후보=entry.get("후보") or [], 글=judged["글"], 꼬리표=tags, 품=_qty(entry, found))
|
||||
if found and found.get("갈리는칸"):
|
||||
out["갈리는칸"] = found["갈리는칸"]
|
||||
@@ -172,21 +197,30 @@ def _names_only(line: dict, ctx: Ctx, why: str, full: bool) -> dict:
|
||||
|
||||
|
||||
def _material(line: dict, ctx: Ctx, full: bool) -> dict:
|
||||
found = hm._exact(line, "자재", ctx.got)
|
||||
if len(found) == 1:
|
||||
"""자재 · 별도계상 줄(358-2 · `M02_Hopyo_Material`) — 「별도계상」 글 = 꼬리표 · 맞음 =
|
||||
이름 · 규격 같음 하나 · 또는 바탕 이름 · 규격 수 같은 한 품목(변형 = 첫째 + 꼬리표)."""
|
||||
me, apart = hmat.clean(line)
|
||||
tags = [hmat.APART] if apart else []
|
||||
found = hm._exact(me, "자재", ctx.got)
|
||||
text = "이름 · 규격 같음"
|
||||
if len(found) != 1:
|
||||
sure, more = hmat.sure(me)
|
||||
found, text, tags = [r["one"] for r in sure], "바탕 이름 · 규격 같음", tags + more
|
||||
if found:
|
||||
one = found[0]
|
||||
conv = hm.factor(line.get("단위"), one["단위"], line.get("규격"))
|
||||
target = {**hm._public(one), "입력값": {}, "못읽음": [], "출처": {}, "환산": conv}
|
||||
target.update(몫=None, 배=conv["배"])
|
||||
ok = conv["배"] is not None and conv["갈래"] != "추정"
|
||||
return {"상태": "맞음", "체크": ok, "대상": [target], "후보": [], "글": "이름 · 규격 같음"}
|
||||
cands = [{**c, "판정": "후보"} for c in hm.candidates(line, "자재", ctx.got, 5)] if full else []
|
||||
return {
|
||||
"상태": "후보" if cands or not full else "없음",
|
||||
"체크": False,
|
||||
"대상": [],
|
||||
"후보": cands,
|
||||
}
|
||||
cands = [
|
||||
{**hm._public(x), "점수": None, "판정": "맞음" if not at else "후보", "글": text}
|
||||
for at, x in enumerate(found[:6])
|
||||
]
|
||||
out = {"상태": "맞음", "체크": ok, "대상": [target], "후보": cands if full else []}
|
||||
return {**out, "글": text, "꼬리표": tags}
|
||||
cands = hmat.candidates(me) if full else []
|
||||
state = "후보" if cands or not full else "없음"
|
||||
return {"상태": state, "체크": False, "대상": [], "후보": cands, "꼬리표": tags}
|
||||
|
||||
|
||||
def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, Any]:
|
||||
@@ -213,22 +247,52 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A
|
||||
entry = ctx.cache.get(fit.unit_key(line)) if kind not in hm.MATERIAL_KINDS else None
|
||||
if alias and alias.get("갈래") in SKIP:
|
||||
return {**out, "상태": "뺌", "뺌": alias["갈래"], "별칭": _alias_info(alias, False)}
|
||||
if alias and alias.get("갈래") == "짝" and alias.get("짝") and _ledger_off(entry, alias):
|
||||
loose, alias = alias, None # 숫자 판정 맞음 = 장부 짝 아닌 로직 → 판정 먼저(358-3 ①)
|
||||
loose = {**loose, "_까닭": LEDGER_OFF}
|
||||
if alias and alias.get("갈래") == "짝" and alias.get("짝"):
|
||||
return {**out, **_aliased(line, ctx, alias, entry)}
|
||||
if kind in hm.MATERIAL_KINDS:
|
||||
got = _aliased(line, ctx, alias, entry)
|
||||
elif kind in hm.MATERIAL_KINDS:
|
||||
got = _material(line, ctx, full)
|
||||
elif entry and entry["판정"]["글"] not in (fit.NO_NUMBER, "우리 로직 준비 중"):
|
||||
got = _numeric(line, ctx, entry) if full else _brief(entry)
|
||||
got = _numeric(line, ctx, entry) if full else _brief(entry, line, ctx)
|
||||
else:
|
||||
why = "셈 중" if not ctx.calc["끝"] else fit.NO_NUMBER
|
||||
got = _names_only(line, ctx, why, full)
|
||||
return {**out, **(_loose(got, loose, line, ctx) if loose and full else got)}
|
||||
got = _loose(got, loose, line, ctx) if loose and full else got
|
||||
return {**out, **(_grouped(got, line, ctx) if full and kind not in hm.MATERIAL_KINDS else got)}
|
||||
|
||||
|
||||
def _grouped(got: dict, line: dict, ctx: Ctx) -> dict:
|
||||
"""묶음 표시(358-1) — 후보 · 이름후보(옛 324 다섯)마다 뜻겹침 · 출처같음(조항 ≥ 0.9) · 책 ·
|
||||
차례 · 무게 · 상태 그대로(아무것도 숨기지 않음 · 묶음은 화면 몫)."""
|
||||
mine = hm.words(line.get("이름"))
|
||||
theirs = hm.fix_typo(((line.get("근거") or {}).get("출처")) or [])
|
||||
table = hopyo_basis.ours()
|
||||
|
||||
def mark(c: dict) -> dict:
|
||||
one, our = ctx.names.get(c.get("로직")), table.get(c.get("로직"))
|
||||
if one is None:
|
||||
return c
|
||||
same = bool(theirs and our) and hm.clause(theirs, our)[0] >= 0.9
|
||||
book = (our or {}).get("책", "")
|
||||
return {**c, "뜻겹침": hm.near(mine, one["words"]), "출처같음": same, "책": book}
|
||||
|
||||
named = [mark(c) for c in hm.name_cands(line, ctx.got)]
|
||||
return {**got, "후보": [mark(c) for c in got.get("후보") or []], "이름후보": named}
|
||||
|
||||
|
||||
def _ledger_off(entry: dict | None, alias: dict) -> bool:
|
||||
"""장부 짝(정확 열쇠)이 숫자 판정 맞음 1위를 안 담음."""
|
||||
codes = {p.get("로직") for p in alias.get("짝") or []}
|
||||
return bool(entry and entry["판정"]["상태"] == "맞음" and entry["차례"][0] not in codes)
|
||||
|
||||
|
||||
def _loose(got: dict, alias: dict, line: dict, ctx: Ctx) -> dict:
|
||||
"""규격 다른 별칭 — 꼬리표 「별칭 · 규격 다름」 + 짝 로직 · 자재가 후보에 없으면 끝에 덤(상태 ·
|
||||
대상 · 옵션은 이 줄 숫자 그대로)."""
|
||||
tags = [*(got.get("꼬리표") or []), "별칭 · 규격 다름"]
|
||||
"""규격 다른 별칭 · 숫자 판정과 다른 장부 짝 — 꼬리표(「별칭 · 규격 다름」 · 「장부와 다름」) +
|
||||
짝 로직 · 자재가 후보에 없으면 끝에 덤(상태 · 대상 · 옵션은 이 줄 숫자 그대로)."""
|
||||
word = alias.get("_까닭") or LOOSE
|
||||
tags = [*(got.get("꼬리표") or []), word]
|
||||
have = {c.get("로직") or c.get("자재") for c in got.get("후보") or []}
|
||||
have |= {t.get("로직") or t.get("자재") for t in got.get("대상") or []}
|
||||
extra = []
|
||||
@@ -240,13 +304,17 @@ def _loose(got: dict, alias: dict, line: dict, ctx: Ctx) -> dict:
|
||||
for k, v in _pair(p, line, ctx).items()
|
||||
if k in ("로직", "자재", "이름", "단위")
|
||||
}
|
||||
extra.append({**one, "점수": None, "판정": "후보", "글": "별칭 · 규격 다름"})
|
||||
extra.append({**one, "점수": None, "판정": "후보", "글": word})
|
||||
return {**got, "꼬리표": tags, "후보": [*(got.get("후보") or []), *extra]}
|
||||
|
||||
|
||||
def _brief(entry: dict) -> dict:
|
||||
state = entry["판정"]["상태"]
|
||||
return {"상태": state, "체크": state == "맞음", "글": entry["판정"]["글"]}
|
||||
def _brief(entry: dict, line: dict, ctx: Ctx) -> dict:
|
||||
"""목록 수(`full` 아님) — 체크는 확인 창과 같은 `_check`."""
|
||||
return {
|
||||
"상태": entry["판정"]["상태"],
|
||||
"체크": _check(entry, line, ctx),
|
||||
"글": entry["판정"]["글"],
|
||||
}
|
||||
|
||||
|
||||
def _alias_info(alias: dict, basis_off: bool) -> dict:
|
||||
@@ -255,7 +323,7 @@ def _alias_info(alias: dict, basis_off: bool) -> dict:
|
||||
|
||||
|
||||
def _aliased(line: dict, ctx: Ctx, alias: dict, entry: dict | None) -> dict:
|
||||
"""⑤ 장부 짝(정확 열쇠) — 체크 = 근거 같음 · 숫자 판정 맞음이 다른 로직 아님."""
|
||||
"""⑤ 장부 짝(정확 열쇠 · 숫자 판정 맞음 1위를 담음 또는 판정 맞음 아님) — 체크 = 근거 같음."""
|
||||
targets = [_pair(p, line, ctx) for p in alias["짝"]]
|
||||
codes = {t["로직"] for t in targets if t.get("로직")}
|
||||
theirs = ((line.get("근거") or {}).get("출처")) or []
|
||||
@@ -263,12 +331,9 @@ def _aliased(line: dict, ctx: Ctx, alias: dict, entry: dict | None) -> dict:
|
||||
off = bool(theirs and codes) and all(
|
||||
hm.clause(theirs, table[c])[0] < 0.9 for c in codes if c in table
|
||||
)
|
||||
other = bool(entry and entry["판정"]["상태"] == "맞음" and entry["차례"][0] not in codes)
|
||||
check = not off and not other
|
||||
tags = ["별칭 — " + " · ".join(str(x) for x in (alias.get("층"), alias.get("때")) if x)]
|
||||
tags += ["근거 다름"] * off
|
||||
tags += [f"숫자 판정 맞음 = {entry['차례'][0]}"] if other else []
|
||||
out = {"상태": "별칭", "체크": check, "대상": targets, "글": "별칭 장부 짝", "꼬리표": tags}
|
||||
out = {"상태": "별칭", "체크": not off, "대상": targets, "글": "별칭 장부 짝", "꼬리표": tags}
|
||||
out["별칭"] = _alias_info(alias, off)
|
||||
if entry and entry.get("품"):
|
||||
out["품"] = _qty(entry, (entry.get("옵션") or {}).get(next(iter(codes), None)))
|
||||
@@ -295,10 +360,13 @@ def whole(hopyo: dict, ctx: Ctx) -> dict | None:
|
||||
|
||||
|
||||
def hopyo_view(hopyo: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, Any]:
|
||||
"""호표 하나 — 줄마다 맞춤 · `맞춤` = [체크 제안 수, 줄 수] · 통째 · 셈 · 만든."""
|
||||
"""호표 하나 — 줄마다 맞춤 · `맞춤` = [체크 제안 수, 줄 수] · 연결할(% · 노임 · 뺌 밖 줄 수) ·
|
||||
연결된(그중 체크 제안 수 · 358-3) · 통째 · 셈 · 만든."""
|
||||
lines = [{**one, "맞춤": match_line(one, ctx, full)} for one in hopyo.get("줄") or []]
|
||||
check = sum(1 for one in lines if one["맞춤"]["체크"])
|
||||
out = {**hopyo, "줄": lines, "맞춤": [check, len(lines)]}
|
||||
need = [one for one in lines if one["맞춤"]["상태"] not in OUTSIDE]
|
||||
out = {**hopyo, "줄": lines, "맞춤": [check, len(lines)], "연결할": len(need)}
|
||||
out["연결된"] = sum(1 for one in need if one["맞춤"]["체크"])
|
||||
if full:
|
||||
made = ctx.done.get(hopyo_build.mark(ctx.key, int(hopyo["번"])))
|
||||
out.update(통째=whole(hopyo, ctx), 셈=ctx.calc)
|
||||
|
||||
@@ -292,10 +292,14 @@ def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[st
|
||||
|
||||
|
||||
def candidates(
|
||||
line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int | None = 5
|
||||
line: dict[str, Any],
|
||||
pool: str,
|
||||
got: dict[str, Any] | None = None,
|
||||
limit: int | None = 5,
|
||||
fold: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit`
|
||||
(None = 모두)."""
|
||||
(None = 모두) · `fold` = 로직도 (이름, 규격, 단위)로 접음(옛 324 꼴)."""
|
||||
me = tidy(line.get("이름"))
|
||||
grams, unit = _grams(me), line.get("단위")
|
||||
text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}")
|
||||
@@ -313,7 +317,8 @@ def candidates(
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
out = []
|
||||
for score, one in scored:
|
||||
label = one.get("로직") or (one["이름"], one.get("규격"), one["단위"]) # 로직은 접지 않음
|
||||
label = (one["이름"], one.get("규격"), one["단위"])
|
||||
label = label if fold else one.get("로직") or label # 로직은 접지 않음(fold 아니면)
|
||||
if label in seen:
|
||||
continue
|
||||
seen.add(label)
|
||||
@@ -323,6 +328,19 @@ def candidates(
|
||||
return out
|
||||
|
||||
|
||||
def name_cands(line: dict[str, Any], got: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||
"""이름 닮은 로직 다섯(358-1 · 옛 324 데스크탑 판 그대로) — 이름 같음 · 씨앗(점수 9) 먼저 +
|
||||
이름 점수 후보(로직도 이름 · 규격 · 단위로 접음) · 숫자 판정 차례와 따로(표시 묶음만)."""
|
||||
got = got or targets()
|
||||
same = [_public(x) for x in _exact(line, "로직", got)]
|
||||
rest = [
|
||||
c
|
||||
for c in candidates(line, "로직", got, 5, fold=True)
|
||||
if {k: v for k, v in c.items() if k != "점수"} not in same
|
||||
]
|
||||
return ([{**x, "점수": 9} for x in same] + rest)[:5]
|
||||
|
||||
|
||||
# ── 인용 조항 · 이름 · 근거 차례(338 · 설계 2-4 B 차례) ─────────────────────
|
||||
_OLD_TEXT = re.compile(r"(20(1\d|2[0-4])|1\d년|2[0-4]년|임도품셈|임도표준품셈|사방품셈)")
|
||||
_TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 13-114 → 13-11-4
|
||||
@@ -437,11 +455,12 @@ def view(folder: Path, key: str, no: int | None = None, to: Path | None = None)
|
||||
doc = ctx.doc
|
||||
if no is None:
|
||||
hopyos = [lines.hopyo_view(h, ctx, False) for h in doc.get("호표") or []]
|
||||
keep = ("번", "이름", "규격", "단위", "금액", "맞춤", "행")
|
||||
keep = ("번", "이름", "규격", "단위", "금액", "맞춤", "행", "연결할", "연결된")
|
||||
brief = [{k: h.get(k) for k in keep if k in h} for h in hopyos]
|
||||
ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)]
|
||||
head = {**hopyo_store.brief(doc), "검산": doc.get("검산"), "셈": ctx.calc}
|
||||
return {**head, "호표": brief, "맞춤": ready}
|
||||
links = {k: sum(h[k] for h in hopyos) for k in ("연결할", "연결된")}
|
||||
return {**head, "호표": brief, "맞춤": ready, **links}
|
||||
hopyo = ctx.units.get(int(no))
|
||||
if hopyo is None:
|
||||
raise store.StoreError(404, f"없는 호표 「제{no}호표」")
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""M02 일위대가 실무자료 자재 · 별도계상 줄 맞춤(PLAN 358-2) — `Line._material` 이 부름.
|
||||
|
||||
줄 다듬기 = 「별도계상」 글은 표시로 뗌(「조약돌(별도계상)」 · 규격 「별도계상」) · m/m = mm.
|
||||
바탕 이름 = 괄호 · 지역 · 관급/사급 꼬리 뗀 이름(「레미콘(서울)-사급」 → 레미콘).
|
||||
규격 맞음(0~1) = 정규 규격 같음 1 · 범위(「3.0≤T<4.5」)에 듦 1 · 그 밖 = 줄 규격 수가
|
||||
자재 규격 수에 있는 몫(단위 배 1 · 10 · 1000 · 2 % 허용 · 면목 0.03 m = 30 mm) ·
|
||||
줄 괄호 글 낱말(「몸통」)은 자재 이름에 들어야 함.
|
||||
맞음 = ① 이름 · 규격 같음 하나(`Match._exact`) ② 바탕 이름 · 단위 · 규격 맞음 1 이 한 품목뿐
|
||||
(지역 · 관급/사급만 다른 변형 = 한 품목 · 같은 단위 먼저 · 줄 글의 관급/사급 · 지역 먼저 ·
|
||||
없으면 별도계상 줄 = 관급 · 재료비 줄 = 사급 · 지역 없는 것 · M01 차례 → 꼬리표 「못 가림」).
|
||||
그 밖 = 후보 다섯(변형은 하나로 접고 글에 수). 곳 낱말 = M01 레미콘 이름에서(자료).
|
||||
가져오기 세 칸(`marks`) = 별도계상(표시 → 별도계상 · 아니면 직접계산) · 자재구분(고른
|
||||
자재 이름 · 줄 글) · 운반비(「운반비 미포함 · 별도」 → 미포함). 로직 판정과 무관.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||||
|
||||
mm = hm.mm
|
||||
APART = "별도계상"
|
||||
SUPPLY = ("관급", "사급")
|
||||
NO_REGION = ("전국", "전지역")
|
||||
REGIONS = (
|
||||
*"서울 인천 부산 대구 광주 대전 울산 세종 제주 경기 강원 충북 충남 전북 전남 경북 경남".split(),
|
||||
*"충청 전라 경상 용인 포천 강릉 원주 창원 경주 구미 김천 안동 포항 광양 목포 전주 공주".split(),
|
||||
*"보령 서산 아산 천안 제천 청주 충주 춘천 김해 양산 거제 통영 진주 밀양".split(),
|
||||
*"여주 이천 가평 연천".split(),
|
||||
)
|
||||
SKIP_WORDS = frozenset({"조합공통품목"})
|
||||
_APART = re.compile(r"\(?\s*별도\s*계상\s*\)?")
|
||||
_NUM = re.compile(r"\d+(?:\.\d+)?")
|
||||
_RANGE = re.compile(r"(\d+(?:\.\d+)?)\s*(<=|≤|<)\s*[A-Za-z]\s*(<=|≤|<)\s*(\d+(?:\.\d+)?)")
|
||||
_TAIL = re.compile(r"[\s,\-]*([가-힣]+)$")
|
||||
SCALES = (1, 10, 100, 1000, 0.1, 0.01, 0.001)
|
||||
|
||||
|
||||
def _nfkc(text: Any) -> str:
|
||||
return unicodedata.normalize("NFKC", str(text or "")).replace("m/m", "mm")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _places() -> frozenset[str]:
|
||||
"""곳 낱말 — M01 레미콘 이름의 지역 · 시군 낱말(자료에서 · 「홍천군」 · 「미원면」)."""
|
||||
names = (one["이름"] for one in hm.targets()["자재"] if one["이름"].startswith("레미콘("))
|
||||
words = {w for n in names for w in re.findall(r"[가-힣]{2,}", _nfkc(n))}
|
||||
return frozenset(words - {"레미콘", "조합공통품목", *SUPPLY})
|
||||
|
||||
|
||||
def _region(word: str) -> bool:
|
||||
return word.startswith(REGIONS) or word in NO_REGION or word in _places()
|
||||
|
||||
|
||||
def _paren_words(name: str) -> set[str]:
|
||||
return {w for g in re.findall(r"\(([^()]*)\)", name) for w in re.findall(r"[가-힣]{2,}", g)}
|
||||
|
||||
|
||||
def base(name: Any) -> str:
|
||||
"""바탕 이름 — 괄호 글 · 끝 지역 · 관급/사급 꼬리 뗌(정규화)."""
|
||||
text = _nfkc(name)
|
||||
while "(" in text and (cut := re.sub(r"\([^()]*\)", "", text)) != text:
|
||||
text = cut
|
||||
while (m := _TAIL.search(text)) and (m.group(1) in SUPPLY or _region(m.group(1))):
|
||||
if m.start() == 0:
|
||||
break
|
||||
text = text[: m.start()]
|
||||
for w in SUPPLY:
|
||||
text = text[: -len(w)] if text.endswith(w) and len(text) > len(w) else text
|
||||
return mm.plain(text)
|
||||
|
||||
|
||||
def clean(line: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
||||
"""(「별도계상」 글 뗀 줄, 별도계상 표시) — 갈래 별도계상 줄도 표시."""
|
||||
out, apart = dict(line), line.get("갈래") == APART
|
||||
for k in ("이름", "규격"):
|
||||
text = str(line.get(k) or "")
|
||||
if _APART.search(text):
|
||||
apart, text = True, _APART.sub("", text).strip(" ,")
|
||||
out[k] = text
|
||||
return out, apart
|
||||
|
||||
|
||||
def _nums(text: str) -> list[float]:
|
||||
return [float(x) for x in _NUM.findall(text)]
|
||||
|
||||
|
||||
def _ranges(text: str) -> list[tuple[float, bool, float, bool]]:
|
||||
return [(float(a), op1 != "<", float(b), op2 != "<") for a, op1, op2, b in _RANGE.findall(text)]
|
||||
|
||||
|
||||
def _words(text: str) -> frozenset[str]:
|
||||
return (
|
||||
frozenset(w for w in re.findall(r"[가-힣]{2,}", text) if not _region(w) and w not in SUPPLY)
|
||||
- SKIP_WORDS
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _pool(got_id: int) -> tuple[list[dict[str, Any]], dict[str, list[dict[str, Any]]]]:
|
||||
"""자재마다 바탕 이름 · 규격 수 · 범위 · 지역 · 관급/사급 — (목록, {바탕: [자재]})."""
|
||||
rows, index = [], {}
|
||||
for at, one in enumerate(hm.targets()["자재"]):
|
||||
name, spec = _nfkc(one["이름"]), _nfkc(one["규격"])
|
||||
words = set(re.findall(r"[가-힣]+", name))
|
||||
row = {
|
||||
"one": one,
|
||||
"at": at,
|
||||
"base": base(name),
|
||||
"nums": _nums(spec),
|
||||
"ranges": _ranges(spec),
|
||||
"spec": mm.plain_spec(spec),
|
||||
"region": sorted(w for w in words if _region(w) and w not in NO_REGION),
|
||||
"supply": next((w for w in SUPPLY if w in name), None),
|
||||
"key": (
|
||||
tuple(sorted(_nums(spec))),
|
||||
mm.plain_unit(one["단위"]),
|
||||
_words(spec) | _words(" ".join(_paren_words(name))),
|
||||
),
|
||||
}
|
||||
row["grams"] = hm._grams(row["base"])
|
||||
rows.append(row)
|
||||
index.setdefault(row["base"], []).append(row)
|
||||
return rows, index
|
||||
|
||||
|
||||
def pool() -> tuple[list[dict[str, Any]], dict[str, list[dict[str, Any]]]]:
|
||||
return _pool(id(hm.targets()))
|
||||
|
||||
|
||||
def _near(x: float, y: float) -> bool:
|
||||
return any(abs(x * s - y) <= 0.02 * max(abs(y), 1e-9) for s in SCALES)
|
||||
|
||||
|
||||
def spec_fit(line: dict[str, Any], row: dict[str, Any]) -> float:
|
||||
"""규격 맞음 0~1 — 같음 · 범위에 듦 · 줄 규격 수가 자재 규격 수에 있는 몫."""
|
||||
text = _nfkc(f"{line.get('규격') or ''} {' '.join(hm._PAREN.findall(str(line.get('이름'))))}")
|
||||
if mm.plain_spec(line.get("규격")) == row["spec"]:
|
||||
return 1.0
|
||||
mine = _nums(text)
|
||||
if row["ranges"]:
|
||||
inside = any(
|
||||
(a < x or (lo and a == x)) and (x < b or (hi and x == b))
|
||||
for x in mine
|
||||
for a, lo, b, hi in row["ranges"]
|
||||
)
|
||||
return 1.0 if inside else 0.0
|
||||
if not mine or not row["nums"]:
|
||||
return 0.0
|
||||
return sum(any(_near(x, y) for y in row["nums"]) for x in mine) / len(mine)
|
||||
|
||||
|
||||
def _wants(line: dict[str, Any]) -> tuple[str | None, list[str]]:
|
||||
"""(관급/사급, 지역 낱말) — 줄 글 · 없으면 별도계상 줄 = 관급 · 재료비 줄 = 사급."""
|
||||
text = _nfkc(f"{line.get('이름')} {line.get('규격')} {line.get('비고') or ''}")
|
||||
words = set(re.findall(r"[가-힣]+", text))
|
||||
supply = next((w for w in SUPPLY if w in text), None)
|
||||
if supply is None:
|
||||
supply = (
|
||||
"관급" if clean(line)[1] else "사급" if (line.get("단가") or {}).get("재료비") else None
|
||||
)
|
||||
return supply, [w for w in words if _region(w) and w not in NO_REGION]
|
||||
|
||||
|
||||
def _prefer(rows: list[dict[str, Any]], line: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""변형 차례 — 줄 글의 관급/사급 · 지역 먼저 · 지역 없는 것 · 엑셀(M01) 차례."""
|
||||
supply, regions = _wants(line)
|
||||
|
||||
def rank(r: dict[str, Any]) -> tuple:
|
||||
hit = any(w[:2] == x[:2] for w in regions for x in r["region"])
|
||||
return (
|
||||
supply is not None and r["supply"] not in (None, supply),
|
||||
bool(regions) and not hit,
|
||||
bool(r["region"]),
|
||||
r["at"],
|
||||
)
|
||||
|
||||
return sorted(rows, key=rank)
|
||||
|
||||
|
||||
def _paren_ok(line: dict[str, Any], row: dict[str, Any]) -> bool:
|
||||
return _paren_words(_nfkc(line.get("이름"))) <= set(re.findall(r"[가-힣]+", row["one"]["이름"]))
|
||||
|
||||
|
||||
def sure(line: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
"""맞음 ② — 바탕 이름 같음 · 단위 · 괄호 낱말 · 규격 맞음 1 이 한 품목 → (변형 차례, 꼬리표) ·
|
||||
아니면 ([], [])."""
|
||||
_, index = pool()
|
||||
rows = [
|
||||
r
|
||||
for r in index.get(base(line.get("이름")), [])
|
||||
if hm.fits(line.get("단위"), r["one"]["단위"]) and _paren_ok(line, r)
|
||||
]
|
||||
full = [r for r in rows if spec_fit(line, r) == 1.0]
|
||||
unit = mm.plain_unit(line.get("단위"))
|
||||
full = [r for r in full if r["key"][1] == unit] or full # 같은 단위 먼저(kg ↔ Ton 둘 다면)
|
||||
if not full or len({r["key"] for r in full}) > 1:
|
||||
return [], []
|
||||
full = _prefer(full, line)
|
||||
tags = [f"지역 · 관급/사급 못 가림 — {len(full)}개 중 첫째"] if len(full) > 1 else []
|
||||
return full, tags
|
||||
|
||||
|
||||
def candidates(line: dict[str, Any], limit: int = 5) -> list[dict[str, Any]]:
|
||||
"""자재 후보 — 바탕 이름 글자 묶음 + 바탕 같음 1 + 단위 0.15 + 규격 맞음 0.5 + 괄호 낱말 0.2 ·
|
||||
뜻 낱말 · 규격 수 둘 다 안 닮으면 뺌 · 변형(지역 · 관급/사급)은 하나로 접음(글 = 변형 수)."""
|
||||
rows, _ = pool()
|
||||
me = base(line.get("이름"))
|
||||
grams, mine = hm._grams(me), hm.words(line.get("이름"))
|
||||
parens = _paren_words(_nfkc(line.get("이름")))
|
||||
scored: dict[tuple, list] = {}
|
||||
for r in rows:
|
||||
if not grams & r["grams"]:
|
||||
continue
|
||||
fit = spec_fit(line, r)
|
||||
if not hm.near(mine, r["one"]["words"]) and fit < 0.5:
|
||||
continue
|
||||
score = 2 * len(grams & r["grams"]) / (len(grams) + len(r["grams"]))
|
||||
score += (r["base"] == me) + 0.15 * hm.fits(line.get("단위"), r["one"]["단위"])
|
||||
score += 0.5 * fit + (0.2 if parens and _paren_ok(line, r) else 0)
|
||||
fold = (r["base"], *r["key"])
|
||||
have = scored.setdefault(fold, [score, []])
|
||||
have[0] = max(have[0], score)
|
||||
have[1].append(r)
|
||||
best = sorted(scored.values(), key=lambda x: (-x[0], x[1][0]["at"]))[:limit]
|
||||
out = []
|
||||
for score, group in best:
|
||||
first = _prefer(group, line)[0]
|
||||
one = {**hm._public(first["one"]), "점수": round(score, 3), "판정": "후보"}
|
||||
out.append(
|
||||
{**one, "글": f"변형 {len(group)}개 — 지역 · 관급/사급"} if len(group) > 1 else one
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def marks(line: dict[str, Any], name: str = "") -> dict[str, str]:
|
||||
"""가져오기 세 칸(Labor_Doc `MARKS` 값) — 별도계상 · 자재구분(고른 자재 `name` →
|
||||
줄 글 → 줄 갈래) · 운반비(모르면 칸 없음 = 기본)."""
|
||||
_, apart = clean(line)
|
||||
out = {"별도계상": APART if apart else "직접계산"}
|
||||
supply = next((w for w in SUPPLY if w in str(name)), None) or _wants(line)[0]
|
||||
if supply:
|
||||
out["자재구분"] = supply
|
||||
text = re.sub(r"\s", "", _nfkc(f"{line.get('규격')} {line.get('비고') or ''}"))
|
||||
if "운반비미포함" in text or "운반비별도" in text:
|
||||
out["운반비"] = "미포함"
|
||||
return out
|
||||
@@ -168,9 +168,12 @@ def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str
|
||||
def _kind(code: str, line: dict[str, Any]) -> str:
|
||||
if code.startswith("PR") or _flat(line["단위"]) == "%":
|
||||
return "%"
|
||||
price = line["단가"]
|
||||
if code[0] == "M":
|
||||
apart = line["비고"].startswith("별산") or line["규격"] == "별도계상"
|
||||
return "별도계상" if apart or not any(line["단가"].values()) else "자재"
|
||||
apart = line["비고"].startswith("별산") or "별도계상" in f"{line['규격']}{line['이름']}"
|
||||
return "별도계상" if apart or not any(price.values()) else "자재"
|
||||
if code[0] == "D" and price.get("재료비") and not price.get("노무비") and not price.get("경비"):
|
||||
return "자재" # 재료비만 = 자재(358-2 · 엑셀꼴과 같음)
|
||||
return _KIND.get(code[0], "기타")
|
||||
|
||||
|
||||
|
||||
@@ -215,7 +215,11 @@ def hopyo_list(
|
||||
) -> dict:
|
||||
"""실무자료 목록 `{줄: [{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}]}`."""
|
||||
folder = _root(session, layer, owner)
|
||||
return {"줄": hopyo_store.rows(folder)}
|
||||
rows = hopyo_store.rows(folder)
|
||||
if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None:
|
||||
for row in rows: # 지금 판 맞춤셈 없는 파일 = 목록 열 때 뒤 셈 예약(358-3 ③)
|
||||
ensure(folder, row["키"])
|
||||
return {"줄": rows}
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/hopyo-picks")
|
||||
|
||||
@@ -65,7 +65,7 @@ export const CARD_HELP: Record<"structure" | "labor" | "area", CardHelp> = {
|
||||
["로직 줄 누름", "넣을 값 창 — 그 로직의 넣을 값 고침"],
|
||||
["수량산출 칸", "수기 입력 · 면적산출 칸 고르기"],
|
||||
["별칭 칸", "줄에 따로 이름 붙임"],
|
||||
["줄 끝 단추", "그 줄을 뺌"],
|
||||
["줄 끝 버튼", "그 줄을 뺌"],
|
||||
["자재 추가", "자재를 골라 자재목록에 넣음"],
|
||||
FOLD,
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_DetailNav.ts
|
||||
* 상세 머리 제목과 [?] 사이 [도면][일위대가][면적산출] 묶음(105-4) — 같은 열의 다른 상세로 옮겨 감.
|
||||
* 지금 상세 단추 = 숨김(113-2) · 단추 크기 = 머리 [다시 열기]와 같은 보통 · 그 열 문서가 없으면 그 단추 꺼짐(번호 목록에 번호 없음).
|
||||
* 지금 상세 단추 = 숨김(113-2) · 단추 크기 = 머리 [새로고침]와 같은 보통 · 그 열 문서가 없으면 그 단추 꺼짐(번호 목록에 번호 없음).
|
||||
* 보임 조건 · 옮겨 가기(confirmLeave → open)는 부른 쪽(`UI_Main`) 몫.
|
||||
* ========================================================================== */
|
||||
|
||||
|
||||
@@ -55,34 +55,49 @@
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 확인 창 줄 표(338 · 339) — 체크 · 갈래 · 수량 · 단위 · 상태 = 줄바꿈 없음(폭 = 글 폭) · 단추 = 세로로 쌓아 좁게 ·
|
||||
품명 · 규격 · 우리 로직 · 옵션 · 품 비교가 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */
|
||||
/* 확인 창 줄 표(338 · 339 · 359-3) — 일위대가 상세 꼴(m02-labrow) · 체크 칸 · 수량 · 단위 = 줄바꿈 없음 ·
|
||||
단추 = 세로로 쌓아 좁게 · 이름 · 별칭 · 품 비교가 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */
|
||||
.m02-hopyo__table.m02-hopyo__lines :is(th, td) {
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-hopyo__table.m02-hopyo__lines
|
||||
:is(th, td):is(:nth-child(1), :nth-child(2), :nth-child(5), :nth-child(6), :nth-child(7)) {
|
||||
.m02-hopyo__table.m02-hopyo__lines td:first-child,
|
||||
.m02-hopyo__lines :is(.m02-labrow__qty, .m02-labrow__unit) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 품명 · 규격 — 낱말 두셋은 한 줄에 */
|
||||
.m02-hopyo__table.m02-hopyo__lines td:nth-child(3),
|
||||
/* 이름 칸 대상 줄 — 키 · 이름 · 배 칸 · 몫 한 줄(상세 이름 칸의 한 줄 말줄임을 풂) */
|
||||
.m02-hopyo__lines .m02-labrow__name > .m02-hopyo__part {
|
||||
display: flex;
|
||||
max-width: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.m02-hopyo__lines .m02-labrow__name > .m02-hopyo__state,
|
||||
.m02-hopyo__lines .m02-labrow__kind > .m02-hopyo__state {
|
||||
display: inline-block;
|
||||
margin-left: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 품 비교 — 항목마다 한 줄(글의 줄바꿈 그대로) */
|
||||
.m02-hopyo__table.m02-hopyo__lines td.m02-hopyo__compare {
|
||||
min-width: 9em;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.m02-hopyo__subhead {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-hopyo-ledger__table td:nth-child(1) {
|
||||
min-width: 7em;
|
||||
}
|
||||
|
||||
.m02-hopyo__table.m02-hopyo__lines td:nth-child(4),
|
||||
.m02-hopyo-ledger__table td:nth-child(2) {
|
||||
min-width: 6em;
|
||||
}
|
||||
|
||||
/* 옵션 · 품 비교 — 항목마다 한 줄(글의 줄바꿈 그대로) */
|
||||
.m02-hopyo__table.m02-hopyo__lines td:is(:nth-child(9), :nth-child(10)) {
|
||||
min-width: 9em;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* 단추 칸 — 단추 · 넣음/뺌을 세로로 쌓아 칸 폭 = 가장 긴 단추 */
|
||||
.m02-hopyo__table.m02-hopyo__lines td.m02-hopyo__acts {
|
||||
width: 1%;
|
||||
@@ -212,6 +227,17 @@
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 고르기 창 후보 묶음(358-1) — 뜻 낱말 안 겹치는 「후보」 = 회색 · 숫자만 닮음 = 접힘 */
|
||||
.m02-hopyo__cand.is-weak {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m02-hopyo__group > summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 별칭 장부(338) — 위 층 줄 = 회색 읽기만 · 고친 줄 = 왼쪽 띠 */
|
||||
.m02-hopyo-ledger__row.is-upper {
|
||||
color: var(--color-text-muted);
|
||||
@@ -278,6 +304,7 @@
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__no {
|
||||
min-width: 4.5em; /* [수정] 단추가 줄마다 같은 자리 */
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -290,6 +317,15 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 목록 숫자 — 다 연결(359-2)이면 녹색 · [수정] 은 번호 바로 오른쪽 · 줄 높이 안 커짐 */
|
||||
.m02-hopyo-sheet__item .m02-hopyo__note.is-linked {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__item > .ui-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.m02-hopyo-sheet__result {
|
||||
max-height: 40%;
|
||||
overflow: auto;
|
||||
|
||||
@@ -36,6 +36,9 @@ export interface HopyoBrief {
|
||||
금액: Money;
|
||||
/** [체크 제안 수, 줄 수] */
|
||||
맞춤: [number, number];
|
||||
/** 연결할 줄 수(% · 노임 · 뺌 밖 · 358-3) · 연결된 줄 수(그중 체크 제안 — 확인 창과 한 규칙) */
|
||||
연결할?: number;
|
||||
연결된?: number;
|
||||
/** 일위대가 시트 행 [시작, 끝](엑셀 1부터 · 329 · 옛 파일 없음) */
|
||||
행?: [number, number];
|
||||
}
|
||||
@@ -58,6 +61,9 @@ export interface HopyoView extends HopyoFile {
|
||||
검산: { 어긋남: string[]; 총괄표: number };
|
||||
호표: HopyoBrief[];
|
||||
맞춤: [number, number];
|
||||
/** 파일 전체 연결할 · 연결된 줄 수(358-3) */
|
||||
연결할?: number;
|
||||
연결된?: number;
|
||||
/** 뒤 숫자 판정 셈(끝나면 맞춤 수가 바뀜) */
|
||||
셈?: HopyoCalc;
|
||||
}
|
||||
@@ -92,6 +98,10 @@ export interface HopyoTarget {
|
||||
/** 후보 판정(338) — 맞음 · 다른 점 · 후보 */
|
||||
판정?: "맞음" | "다른 점" | "후보";
|
||||
글?: string;
|
||||
/** 묶음 표시(358-1 · 로직 후보만) — 품명 뜻 낱말 겹침 · 엑셀 출처 조항 같음(≥ 0.9) · 책(산림품셈 · 건설품셈) */
|
||||
뜻겹침?: boolean;
|
||||
출처같음?: boolean;
|
||||
책?: string;
|
||||
}
|
||||
|
||||
/** 맞춤 대상 하나(338 · 로직 여럿 가능) — 배 = 환산 × 몫 · 우리 수량 = 엑셀 수량 × 배 */
|
||||
@@ -124,6 +134,8 @@ export interface HopyoMatch {
|
||||
체크: boolean;
|
||||
대상: HopyoPart[];
|
||||
후보?: HopyoTarget[];
|
||||
/** 이름 닮은 로직 다섯(358-1 · 옛 324 판 그대로 · 숫자 판정 차례와 따로 · 로직 줄만) */
|
||||
이름후보?: HopyoTarget[];
|
||||
글?: string;
|
||||
꼬리표?: string[];
|
||||
품?: { 엑셀: HopyoQty; 우리: HopyoQty; 같음: Record<string, boolean> };
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Check.ts
|
||||
* 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 ·
|
||||
* 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [확인하고 사전조합 만들기]로 서버에 보냄.
|
||||
* · 자리 = 실무자료 본 칸(호표 누름) · [로직 추가] 「실무 호표」 모달 · 엑셀 보기 창 펼침 [확인 창 열기]
|
||||
* 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1 · 359-3) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 ·
|
||||
* 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [사전조합 생성]으로 서버에 보냄.
|
||||
* · 자리 = 엑셀 보기 창 호표 [수정] · [로직 추가] 「실무 호표」 모달
|
||||
* · 머리 = 호표 · 「제안 n / 줄 m」 · 숫자 판정 셈 중이면 3 초마다 다시 읽음(끝나면 멈춤 · 만진 줄은 그대로)
|
||||
* · 줄 = [✓] · 갈래 · 품명 · 규격 · 수량 · 단위 · 상태(맞음 · 별칭 · 다른 점 · 후보) · 우리 로직(여럿 · 몫 · 배) ·
|
||||
* 옵션(칸=값 · 출처 · 못 가름) · 품 비교(엑셀 → 우리 ✓ / ≠) · [고르기] [원문 보기] [엑셀에서 보기] · 뺌 까닭
|
||||
* · 줄 표 = 구조물 조합 일위대가 상세 꼴(로직 표 · 자재 표 · 품 비교 · 단추 — `M02_MasterTemplete_Hopyo_CheckRows`)
|
||||
* · 금액 = 줄마다 서버 calc 미리 셈(`logic/M02_MasterTemplete_Hopyo_Draft` 문서 · 저장 안 함 · 줄마다 최신 응답만)
|
||||
* · 대상 · 옵션을 바꾸면 서버 `guess`(저장 안 함 · 줄마다 최신 응답만)가 우리 품을 다시 셈
|
||||
* · 캐시 = sessionStorage `m02-hopyo-check:{층}:{주인}:{키}:{번}`(조작 즉시 · 만들기 성공하면 지움) · 자동 저장 없음
|
||||
* · 화면 잠금은 [확인하고 사전조합 만들기] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만.
|
||||
* 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) — 화면은 보이기 · 체크 · 고름만.
|
||||
* · 화면 잠금은 [사전조합 생성] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만.
|
||||
* 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build` · `/labor/{열}/calc`) — 화면은 보이기 · 체크 · 고름만.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -36,22 +36,23 @@ import {
|
||||
type HopyoPart,
|
||||
type HopyoScope,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { builtWord, openHopyoChoose, targetText } from "./M02_MasterTemplete_Hopyo_Choose";
|
||||
import {
|
||||
builtWord,
|
||||
inputsText,
|
||||
openHopyoChoose,
|
||||
sourceText,
|
||||
targetText,
|
||||
} from "./M02_MasterTemplete_Hopyo_Choose";
|
||||
checkTables,
|
||||
type LinePrice,
|
||||
type RowsContext,
|
||||
} from "./M02_MasterTemplete_Hopyo_CheckRows";
|
||||
import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
|
||||
import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { calcLabor, fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { draftDoc } from "./logic/M02_MasterTemplete_Hopyo_Draft";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export const POLL_MS = 3000;
|
||||
/** 사전조합을 만들었음(document 알림) — 좌측 「일위대가 사전조합」 목록 다시 */
|
||||
export const HOPYO_BUILT = "m02-hopyo-built";
|
||||
const OUT = ["우리에 없음", "안 씀"] as const;
|
||||
/** 미리 셈 문서 열 이름(서버 calc 는 보낸 문서로 셈 · 저장 안 함) */
|
||||
const DRAFT_COLUMN = "hopyo-draft";
|
||||
|
||||
/** 확인 창 캐시 — 사용자가 만진 것만(서버 제안 위에 덮어 보임) */
|
||||
export interface CheckDraft {
|
||||
@@ -74,6 +75,14 @@ export function loadDraft(k: string): CheckDraft {
|
||||
return empty();
|
||||
}
|
||||
}
|
||||
/** 확인 창을 열어 만진 적 있음(캐시 있음) */
|
||||
export function hasDraft(k: string): boolean {
|
||||
try {
|
||||
return sessionStorage.getItem(k) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function saveDraft(k: string, d: CheckDraft): void {
|
||||
try {
|
||||
sessionStorage.setItem(k, JSON.stringify(d));
|
||||
@@ -173,7 +182,7 @@ export function newest(): (key: number) => () => boolean {
|
||||
};
|
||||
}
|
||||
|
||||
/** [맞음 · 별칭 모두 체크] 대상 줄 */
|
||||
/** [맞음 · 별칭 일괄 체크] 대상 줄 */
|
||||
export const sureLines = (lines: HopyoLine[], d: CheckDraft): HopyoLine[] =>
|
||||
lines.filter((l) => ["맞음", "별칭"].includes(l.맞춤.상태) && checkable(l, d));
|
||||
|
||||
@@ -196,13 +205,11 @@ export interface CheckOptions {
|
||||
target?: HopyoScope;
|
||||
/** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */
|
||||
canWrite: boolean;
|
||||
/** [← 호표 목록](본 칸) */
|
||||
onBack?: () => void;
|
||||
/** 만들었음 — [로직 추가] 참조 줄 · 모달 닫기 */
|
||||
onBuilt?: (got: HopyoBuilt, hopyo: HopyoOne["호표"]) => void;
|
||||
}
|
||||
|
||||
/** 확인 창 몸 — 본 칸 · 모달이 같이 씀 */
|
||||
/** 확인 창 몸 — 모달(`openHopyoCheck`)에 담김 */
|
||||
export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
|
||||
const root = el("div", {
|
||||
className: "m02-hopyo m02-hopyo-check",
|
||||
@@ -216,6 +223,9 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
|
||||
let seq = 0;
|
||||
let timer = 0;
|
||||
const ask = newest(); // 줄마다 최신 미리 셈만
|
||||
const askPrice = newest(); // 줄마다 최신 금액 미리 셈만
|
||||
const priced = new Map<number, { sig: string; got: LinePrice }>();
|
||||
let pending = false;
|
||||
const keep = (): void => saveDraft(k, d);
|
||||
|
||||
async function load(quiet = false): Promise<void> {
|
||||
@@ -305,161 +315,76 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
|
||||
});
|
||||
}
|
||||
|
||||
function partCell(line: HopyoLine): HTMLElement {
|
||||
const parts = partsOf(line, d);
|
||||
return el("td", {
|
||||
className: "m02-hopyo__parts",
|
||||
children: parts.map((p, at) => {
|
||||
const times = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__times",
|
||||
attrs: { type: "number", step: "any", min: "0", value: p.배 == null ? "" : String(p.배) },
|
||||
}) as HTMLInputElement;
|
||||
times.title = "배 — 우리 수량 = 엑셀 수량 × 배";
|
||||
times.disabled = !opt.canWrite;
|
||||
times.addEventListener("change", () => {
|
||||
const v = times.value === "" ? null : Number(times.value);
|
||||
d.대상[line.차례] = parts.map((x, i) => (i === at ? { ...x, 배: v } : x));
|
||||
keep();
|
||||
draw();
|
||||
});
|
||||
const share = p.몫 != null ? ` · 몫 ${p.몫}` : "";
|
||||
return el("div", {
|
||||
className: "m02-hopyo__part",
|
||||
children: [
|
||||
el("span", {
|
||||
text: `${targetText(p)} ${p.로직 ?? p.자재 ?? ""}${share}`.trim(),
|
||||
attrs: { title: p.어디 ?? "" },
|
||||
}),
|
||||
times,
|
||||
...(p.로직
|
||||
? [
|
||||
createButton({
|
||||
label: "옵션",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => editOptions(line, at),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}),
|
||||
});
|
||||
/** 줄마다 금액 미리 셈(359-3) — 서버 calc(저장 안 함 · 줄마다 최신 응답만 · 문서가 같으면 다시 안 셈) */
|
||||
function price(line: HopyoLine): LinePrice | null {
|
||||
const doc = draftDoc(line, partsOf(line, d));
|
||||
if (!doc) return null;
|
||||
const sig = JSON.stringify(doc);
|
||||
const have = priced.get(line.차례);
|
||||
if (have?.sig === sig) return have.got;
|
||||
const slot = { sig, got: {} as LinePrice };
|
||||
priced.set(line.차례, slot);
|
||||
const mine = askPrice(line.차례);
|
||||
calcLabor(target.layer, DRAFT_COLUMN, doc, {}, target.owner).then(
|
||||
(calc) => mine() && ((slot.got = { calc }), soon()),
|
||||
(error: unknown) => mine() && ((slot.got = { fail: why(error) }), soon()),
|
||||
);
|
||||
return slot.got;
|
||||
}
|
||||
/** 응답 여럿 = 한 번 그림(뒤 탭에서도 — rAF 는 멈춤) */
|
||||
function soon(): void {
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
window.setTimeout(() => {
|
||||
pending = false;
|
||||
if (root.isConnected) draw();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function lineRow(line: HopyoLine): HTMLElement {
|
||||
const m = line.맞춤;
|
||||
const parts = partsOf(line, d);
|
||||
const box = el("input", { attrs: { type: "checkbox", "data-check": String(line.차례) } });
|
||||
const input = box as HTMLInputElement;
|
||||
input.checked = isChecked(line, d) && checkable(line, d);
|
||||
input.disabled = !opt.canWrite || !checkable(line, d);
|
||||
input.addEventListener("change", () => {
|
||||
d.체크[line.차례] = input.checked;
|
||||
const ctx = (): RowsContext => ({
|
||||
canWrite: opt.canWrite,
|
||||
d,
|
||||
price,
|
||||
check: (line, on) => {
|
||||
d.체크[line.차례] = on;
|
||||
keep();
|
||||
draw();
|
||||
});
|
||||
const out = el("select", {
|
||||
className: "ui-input ui-input--sm",
|
||||
attrs: { title: "뺌 — 사전조합에 안 넣고 장부에 남김" },
|
||||
children: [
|
||||
el("option", { text: "넣음", attrs: { value: "" } }),
|
||||
...OUT.map((x) => el("option", { text: `뺌 — ${x}`, attrs: { value: x } })),
|
||||
],
|
||||
}) as HTMLSelectElement;
|
||||
out.value = d.뺌[line.차례] ?? "";
|
||||
out.disabled = !opt.canWrite || m.상태 === "%";
|
||||
out.addEventListener("change", () => {
|
||||
if (out.value) d.뺌[line.차례] = out.value;
|
||||
},
|
||||
out: (line, value) => {
|
||||
if (value) d.뺌[line.차례] = value;
|
||||
else delete d.뺌[line.차례];
|
||||
d.체크[line.차례] = !!out.value || d.체크[line.차례];
|
||||
d.체크[line.차례] = !!value || d.체크[line.차례];
|
||||
keep();
|
||||
draw();
|
||||
});
|
||||
const sheet = (행: [number, number]): void =>
|
||||
openSheet({
|
||||
scope: opt.scope,
|
||||
key: opt.key,
|
||||
파일: got?.파일 ?? "",
|
||||
canWrite: opt.canWrite,
|
||||
target,
|
||||
단산행: 행,
|
||||
});
|
||||
const acts = [
|
||||
...(opt.canWrite && m.상태 !== "%"
|
||||
? [
|
||||
createButton({
|
||||
label: "고르기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openHopyoChoose({
|
||||
line,
|
||||
openSheet: sheet,
|
||||
done: (ts) =>
|
||||
setParts(
|
||||
line,
|
||||
ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
createButton({
|
||||
label: "원문 보기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void showSource(line),
|
||||
},
|
||||
times: (line, at, value) => {
|
||||
d.대상[line.차례] = partsOf(line, d).map((x, i) => (i === at ? { ...x, 배: value } : x));
|
||||
keep();
|
||||
draw();
|
||||
},
|
||||
choose: (line) =>
|
||||
openHopyoChoose({
|
||||
line,
|
||||
openSheet: sheetAt,
|
||||
done: (ts) =>
|
||||
setParts(
|
||||
line,
|
||||
ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })),
|
||||
),
|
||||
}),
|
||||
...(line.근거
|
||||
? [
|
||||
createButton({
|
||||
label: "엑셀에서 보기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => sheet(line.근거!.행),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
out,
|
||||
];
|
||||
const tags = [m.글, ...(m.꼬리표 ?? []), sourceText(line.근거)].filter(Boolean).join("\n");
|
||||
const opts = parts
|
||||
.filter((p) => p.로직)
|
||||
.flatMap((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처).split(/, (?=[^,=]+=)| · /))
|
||||
.filter(Boolean);
|
||||
if (m.갈리는칸?.length) opts.push(`⚠ 못 가름: ${m.갈리는칸.join(", ")}`);
|
||||
const alias = m.별칭?.규격다름 ? " · 규격 다름" : m.별칭?.근거다름 ? " · 근거 다름" : "";
|
||||
return el("tr", {
|
||||
className: `m02-hopyo__line is-${input.checked ? "in" : "out"}`,
|
||||
attrs: { "data-line": String(line.차례), "data-state": m.상태 },
|
||||
children: [
|
||||
el("td", { children: [box] }),
|
||||
td(line.갈래),
|
||||
td(line.이름),
|
||||
td(line.규격),
|
||||
td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"),
|
||||
td(line.단위),
|
||||
el("td", {
|
||||
attrs: { title: tags },
|
||||
children: [
|
||||
el("span", {
|
||||
className: `m02-hopyo__state is-${m.상태.replace(" ", "")}`,
|
||||
text: `${m.상태}${alias}`,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
partCell(line),
|
||||
td(opts.join("\n")), // 옵션 · 품 비교 = 항목마다 한 줄
|
||||
td(
|
||||
compareText(d.품[line.차례] ?? m.품)
|
||||
.split(" · ")
|
||||
.join("\n"),
|
||||
),
|
||||
el("td", { className: "m02-hopyo__acts", children: acts }),
|
||||
],
|
||||
source: (line) => void showSource(line),
|
||||
sheet: sheetAt,
|
||||
options: editOptions,
|
||||
});
|
||||
const sheetAt = (행: [number, number]): void =>
|
||||
openSheet({
|
||||
scope: opt.scope,
|
||||
key: opt.key,
|
||||
파일: got?.파일 ?? "",
|
||||
canWrite: opt.canWrite,
|
||||
target,
|
||||
단산행: 행,
|
||||
});
|
||||
}
|
||||
|
||||
async function make(h: HopyoOne["호표"]): Promise<void> {
|
||||
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
|
||||
@@ -475,8 +400,8 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
|
||||
.map(([a, b]) => `${a} ${b}`)
|
||||
.join(" · ")})`
|
||||
: "") +
|
||||
" — 확인하고 사전조합을 만듦";
|
||||
if (!(await showConfirmDialog(ask, "확인하고 만들기"))) return;
|
||||
" — 사전조합 생성";
|
||||
if (!(await showConfirmDialog(ask, "생성"))) return;
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: opt.no };
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
@@ -559,7 +484,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
|
||||
const tools: HTMLElement[] = [];
|
||||
if (opt.canWrite) {
|
||||
const sure = sureLines(h.줄, d);
|
||||
const all = createButton({ label: `맞음 · 별칭 모두 체크 (${sure.length})`, size: "sm" });
|
||||
const all = createButton({ label: `맞음 · 별칭 일괄 체크 (${sure.length})`, size: "sm" });
|
||||
all.disabled = !sure.length;
|
||||
all.addEventListener("click", () => {
|
||||
for (const l of sure) d.체크[l.차례] = true;
|
||||
@@ -567,30 +492,27 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
|
||||
draw();
|
||||
});
|
||||
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
|
||||
const go = createButton({ label: "확인하고 사전조합 만들기", variant: "filled", size: "sm" });
|
||||
const go = createButton({ label: "사전조합 생성", variant: "filled", size: "sm" });
|
||||
go.disabled = !whole && checked < 1;
|
||||
go.setAttribute("data-hopyo-make", "");
|
||||
go.addEventListener("click", () => void make(h));
|
||||
tools.push(all, go);
|
||||
}
|
||||
const back = opt.onBack
|
||||
? [createButton({ label: "← 호표 목록", variant: "ghost", size: "sm", onClick: opt.onBack })]
|
||||
: [];
|
||||
const head = el("div", {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [
|
||||
...back,
|
||||
el("strong", { text: `제${h.번}호표 ${h.이름}` }),
|
||||
note(h.규격),
|
||||
note(h.단위),
|
||||
note(`엑셀 단가 ${won(h.금액.계)}`),
|
||||
note("금액 칸 위 = 엑셀 · 아래 = 우리(미리 셈)"),
|
||||
note(`제안 ${h.맞춤[0]} / 줄 ${h.맞춤[1]} · 체크 ${checked}`),
|
||||
...(calc && !calc.끝
|
||||
? [note(`숫자 판정 셈 중 ${calc.한}/${calc.모두}${calc.까닭 ? ` · ${calc.까닭}` : ""}`)]
|
||||
: []),
|
||||
...(h.만든 ? [note(`만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}`)] : []),
|
||||
createButton({
|
||||
label: "엑셀에서 보기",
|
||||
label: "엑셀 보기",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openSheet({
|
||||
@@ -630,38 +552,10 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
|
||||
];
|
||||
})()
|
||||
: [];
|
||||
const heads = [
|
||||
"",
|
||||
"갈래",
|
||||
"품명",
|
||||
"규격",
|
||||
"수량",
|
||||
"단위",
|
||||
"상태",
|
||||
"우리 로직 · 배",
|
||||
"옵션",
|
||||
"품 비교",
|
||||
"",
|
||||
];
|
||||
root.replaceChildren(
|
||||
head,
|
||||
...whole,
|
||||
h.줄.length
|
||||
? el("div", {
|
||||
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-hopyo__table m02-hopyo__lines",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
el("tbody", { children: h.줄.map(lineRow) }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
: note("구성 줄 없음"),
|
||||
...(h.줄.length ? checkTables(h.줄, ctx()) : [note("구성 줄 없음")]),
|
||||
...(built ? [builtBlock(built)] : []),
|
||||
);
|
||||
}
|
||||
@@ -670,7 +564,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
|
||||
return root;
|
||||
}
|
||||
|
||||
/** 확인 창 모달 — [로직 추가] 「실무 호표」 · 엑셀 보기 창 [확인 창 열기] · 만들면 닫음 */
|
||||
/** 확인 창 모달 — [로직 추가] 「실무 호표」 · 엑셀 보기 창 호표 [수정] · 만들면 닫음 */
|
||||
export function openHopyoCheck(opt: CheckOptions & { title?: string }): void {
|
||||
openModal({
|
||||
title: opt.title ?? `확인 — 제${opt.no}호표`,
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_CheckRows.ts
|
||||
* 확인 창 줄 표(PLAN 359-3) — 구조물 조합 일위대가 상세 꼴(`Labor_Rows` logicTable · matTable)에 품 비교 · 단추만 더함.
|
||||
* · 로직 표 = [✓] · 차례 · 구분(엑셀 갈래 + 상태) · 이름(우리 로직 · 배 · 옵션 글) · 별칭(엑셀 품명 = 만들 때 별칭) ·
|
||||
* 수량산출(위 엑셀 / 아래 우리 = 엑셀 × 배) · 결과 단위 · 노무비 · 재료비 · 경비 · 합계 · 품 비교 · 단추
|
||||
* · 자재 표 = [✓] · 차례 · 코드 · 이름 · 규격 · 수량산출 · 단위 · 세 칸(자재구분 · 별도계상 · 운반비 · 글만) ·
|
||||
* 가격 다섯 · 엑셀 / 우리 금액 · 단추
|
||||
* · 금액 칸 = 위 작게 엑셀 · 아래 우리(서버 calc 미리 셈 · 저장 안 함) · 셈 중 「…」
|
||||
* · 단추 = [고르기] · [원문 보기] · [엑셀 보기] · [옵션](로직 대상마다) · 드롭다운 추가 · 로직없음 · 사용안함
|
||||
* (화면 글만 · 저장 값 = 빈 값 · 「우리에 없음」 · 「안 씀」 그대로)
|
||||
* 줄 표 = `tableOf`(대상이 모두 자재 · 대상 없는 자재 · 별도계상 줄 = 자재 표).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { HopyoLine, HopyoPart } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import {
|
||||
checkable,
|
||||
compareText,
|
||||
isChecked,
|
||||
partsOf,
|
||||
type CheckDraft,
|
||||
} from "./M02_MasterTemplete_Hopyo_Check";
|
||||
import { inputsText, sourceText } from "./M02_MasterTemplete_Hopyo_Choose";
|
||||
import type { LaborCalc } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks";
|
||||
import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { tableOf } from "./logic/M02_MasterTemplete_Hopyo_Draft";
|
||||
|
||||
/** 드롭다운 — 저장 값(서버 뺌 까닭) → 화면 글 */
|
||||
export const OUT_TEXT: [string, string][] = [
|
||||
["", "추가"],
|
||||
["우리에 없음", "로직없음"],
|
||||
["안 씀", "사용안함"],
|
||||
];
|
||||
const COSTS = ["노무비", "재료비", "경비", "계"] as const;
|
||||
const COST_HEAD = {
|
||||
노무비: "Prev_Labor",
|
||||
재료비: "Prev_Material",
|
||||
경비: "Prev_Expense",
|
||||
계: "Prev_Sum",
|
||||
} as const;
|
||||
|
||||
/** 줄 하나 미리 셈 — 셈 중 = 둘 다 없음 */
|
||||
export interface LinePrice {
|
||||
calc?: LaborCalc;
|
||||
fail?: string;
|
||||
}
|
||||
|
||||
export interface RowsContext {
|
||||
canWrite: boolean;
|
||||
d: CheckDraft;
|
||||
price: (line: HopyoLine) => LinePrice | null;
|
||||
check: (line: HopyoLine, on: boolean) => void;
|
||||
out: (line: HopyoLine, value: string) => void;
|
||||
times: (line: HopyoLine, at: number, value: number | null) => void;
|
||||
choose: (line: HopyoLine) => void;
|
||||
source: (line: HopyoLine) => void;
|
||||
sheet: (행: [number, number]) => void;
|
||||
options: (line: HopyoLine, at: number) => void;
|
||||
}
|
||||
|
||||
const num = (v: number): string =>
|
||||
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
const small = (text: string, cls = "m02-labrow__note"): HTMLElement =>
|
||||
el("small", { className: cls, text, attrs: { title: text } });
|
||||
|
||||
/** 금액 칸 — 위 엑셀(작게) · 아래 우리 */
|
||||
function pairCell(
|
||||
excel: number | null | undefined,
|
||||
ours: number | null | undefined,
|
||||
wait: boolean,
|
||||
label: string,
|
||||
): HTMLElement {
|
||||
const td = moneyCell(excel, ours, wait, label);
|
||||
td.querySelector(".m02-labrow__base")?.setAttribute("title", "엑셀");
|
||||
td.querySelector(".m02-labrow__applied")?.setAttribute("title", "우리(미리 셈)");
|
||||
return td;
|
||||
}
|
||||
|
||||
/** 수량산출 — 위 엑셀 수량 · 아래 우리(엑셀 × 배 · 대상마다) */
|
||||
function qtyCell(line: HopyoLine, parts: HopyoPart[]): HTMLElement {
|
||||
const ours = parts
|
||||
.map((p) => (p.배 === null || line.수량 === null ? "—" : num(line.수량 * p.배)))
|
||||
.join(" · ");
|
||||
return el("td", {
|
||||
className: "m02-labrow__qty",
|
||||
attrs: { "data-label": tc("Labor_QtyCol") },
|
||||
children: [
|
||||
el("small", {
|
||||
className: "m02-labrow__base",
|
||||
text: line.수량 === null ? "—" : `${num(line.수량)} ${line.단위}`.trim(),
|
||||
attrs: { title: "엑셀" },
|
||||
}),
|
||||
el("span", { className: "m02-labrow__applied", text: ours || "—", attrs: { title: "우리" } }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const stateChip = (line: HopyoLine): HTMLElement => {
|
||||
const m = line.맞춤;
|
||||
const alias = m.별칭?.규격다름 ? " · 규격 다름" : m.별칭?.근거다름 ? " · 근거 다름" : "";
|
||||
const tags = [m.글, ...(m.꼬리표 ?? []), sourceText(line.근거)].filter(Boolean).join("\n");
|
||||
return el("span", {
|
||||
className: `m02-hopyo__state is-${m.상태.replace(" ", "")}`,
|
||||
text: `${m.상태}${alias}`,
|
||||
attrs: { title: tags },
|
||||
});
|
||||
};
|
||||
|
||||
/** 대상마다 한 줄 — 키 · 이름 · × 배 칸 · 몫 */
|
||||
function partLines(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLElement[] {
|
||||
return parts.map((p, at) => {
|
||||
const times = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__times",
|
||||
attrs: { type: "number", step: "any", min: "0", value: p.배 == null ? "" : String(p.배) },
|
||||
}) as HTMLInputElement;
|
||||
times.title = "배 — 우리 수량 = 엑셀 수량 × 배";
|
||||
times.disabled = !ctx.canWrite;
|
||||
times.addEventListener("change", () =>
|
||||
ctx.times(line, at, times.value === "" ? null : Number(times.value)),
|
||||
);
|
||||
const key = p.로직 ?? p.자재 ?? (p.호표 !== undefined ? `제${p.호표}호표` : "");
|
||||
return el("div", {
|
||||
className: "m02-hopyo__part",
|
||||
attrs: { title: p.어디 ?? "" },
|
||||
children: [
|
||||
el("span", { className: "m02-labrow__key", text: key }),
|
||||
el("strong", { text: p.이름 ?? "" }),
|
||||
times,
|
||||
...(p.몫 != null ? [small(`몫 ${p.몫}`)] : []),
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 이름 칸 덧글 — 옵션(칸=값 · 출처) · 못 가름 · 셈 못 함 까닭 */
|
||||
function nameNotes(line: HopyoLine, parts: HopyoPart[], price: LinePrice | null): HTMLElement[] {
|
||||
const opts = parts
|
||||
.filter((p) => p.로직)
|
||||
.map((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처))
|
||||
.filter(Boolean);
|
||||
if (line.맞춤.갈리는칸?.length) opts.push(`⚠ 못 가름: ${line.맞춤.갈리는칸.join(", ")}`);
|
||||
const why = [
|
||||
price?.fail,
|
||||
...(price?.calc?.줄 ?? []).map((l) => l.까닭),
|
||||
...(price?.calc?.자재 ?? []).map((m) => m.까닭),
|
||||
].filter(Boolean) as string[];
|
||||
return [
|
||||
...opts.map((t) => small(t, "m02-labrow__note m02-hopyo__opts")),
|
||||
...why.map((t) => small(`셈 못 함: ${t}`, "m02-labrow__empty")),
|
||||
...(parts.some((p) => p.호표 !== undefined) ? [small("하위 호표 = 만든 뒤 셈")] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function checkCell(line: HopyoLine, ctx: RowsContext): HTMLElement {
|
||||
const box = el("input", {
|
||||
attrs: { type: "checkbox", "data-check": String(line.차례) },
|
||||
}) as HTMLInputElement;
|
||||
box.checked = isChecked(line, ctx.d) && checkable(line, ctx.d);
|
||||
box.disabled = !ctx.canWrite || !checkable(line, ctx.d);
|
||||
box.addEventListener("change", () => ctx.check(line, box.checked));
|
||||
return el("td", { children: [box] });
|
||||
}
|
||||
|
||||
function actsCell(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLElement {
|
||||
const m = line.맞춤;
|
||||
const ghost = (label: string, run: () => void): HTMLElement =>
|
||||
createButton({ label, variant: "ghost", size: "sm", onClick: run });
|
||||
const out = el("select", {
|
||||
className: "ui-input ui-input--sm",
|
||||
attrs: { title: "로직없음 · 사용안함 — 사전조합에 안 넣고 장부에 남김", "data-out": "" },
|
||||
children: OUT_TEXT.map(([value, text]) => el("option", { text, attrs: { value } })),
|
||||
}) as HTMLSelectElement;
|
||||
out.value = ctx.d.뺌[line.차례] ?? "";
|
||||
out.disabled = !ctx.canWrite || m.상태 === "%";
|
||||
out.addEventListener("change", () => ctx.out(line, out.value));
|
||||
const options = parts.flatMap((p, at) => {
|
||||
if (!p.로직) return [];
|
||||
const b = ghost("옵션", () => ctx.options(line, at));
|
||||
b.title = `${p.로직} 옵션`;
|
||||
return [b];
|
||||
});
|
||||
return el("td", {
|
||||
className: "m02-hopyo__acts",
|
||||
children: [
|
||||
...(ctx.canWrite && m.상태 !== "%" ? [ghost("고르기", () => ctx.choose(line))] : []),
|
||||
ghost("원문 보기", () => ctx.source(line)),
|
||||
...(line.근거 ? [ghost("엑셀 보기", () => ctx.sheet(line.근거!.행))] : []),
|
||||
...options,
|
||||
out,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const rowOf = (line: HopyoLine, ctx: RowsContext, cells: HTMLElement[]): HTMLElement => {
|
||||
const on = isChecked(line, ctx.d) && checkable(line, ctx.d);
|
||||
return el("tr", {
|
||||
className: `m02-labrow__row m02-hopyo__line is-${on ? "in" : "out"}`,
|
||||
attrs: { "data-line": String(line.차례), "data-state": line.맞춤.상태 },
|
||||
children: [checkCell(line, ctx), ...cells, actsCell(line, partsOf(line, ctx.d), ctx)],
|
||||
});
|
||||
};
|
||||
|
||||
function logicRow(line: HopyoLine, ctx: RowsContext): HTMLElement {
|
||||
const parts = partsOf(line, ctx.d);
|
||||
const price = ctx.price(line);
|
||||
const wait = !!price && !price.calc && !price.fail;
|
||||
const ours = price?.calc?.반영합;
|
||||
const unit = price?.calc?.줄[0]?.분모 ?? parts[0]?.단위 ?? line.단위;
|
||||
const compare = compareText(ctx.d.품[line.차례] ?? line.맞춤.품);
|
||||
return rowOf(line, ctx, [
|
||||
el("td", { className: "m02-labrow__no", text: String(line.차례 + 1) }),
|
||||
el("td", { className: "m02-labrow__kind", children: [line.갈래, stateChip(line)] }),
|
||||
el("td", {
|
||||
className: "m02-labrow__name m02-hopyo__parts",
|
||||
children: [
|
||||
...(parts.length ? partLines(line, parts, ctx) : [small("—", "m02-labrow__muted")]),
|
||||
...nameNotes(line, parts, price),
|
||||
],
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__alias",
|
||||
attrs: { title: `${line.이름} ${line.규격}`.trim() },
|
||||
children: [line.이름, ...(line.규격 ? [small(line.규격)] : [])],
|
||||
}),
|
||||
qtyCell(line, parts),
|
||||
el("td", { className: "m02-labrow__unit", text: unit ?? "" }),
|
||||
...COSTS.map((c) =>
|
||||
pairCell(line.금액[c], price?.calc ? (ours?.[c] ?? null) : null, wait, tc(COST_HEAD[c])),
|
||||
),
|
||||
el("td", { className: "m02-hopyo__compare", text: compare.split(" · ").join("\n") }),
|
||||
]);
|
||||
}
|
||||
|
||||
function matRow(line: HopyoLine, ctx: RowsContext): HTMLElement {
|
||||
const parts = partsOf(line, ctx.d);
|
||||
const price = ctx.price(line);
|
||||
const wait = !!price && !price.calc && !price.fail;
|
||||
const mats = price?.calc?.자재 ?? [];
|
||||
const first = mats[0];
|
||||
const ours = mats.length ? mats.reduce((s, m) => s + (m.반영 ?? 0), 0) : null;
|
||||
const show = (v: number | null | undefined): string =>
|
||||
wait ? "…" : v === null || v === undefined ? "—" : v.toLocaleString("ko-KR");
|
||||
const p = parts[0];
|
||||
return rowOf(line, ctx, [
|
||||
el("td", { className: "m02-labrow__no", text: String(line.차례 + 1) }),
|
||||
el("td", {
|
||||
className: "m02-labrow__code",
|
||||
text: parts.map((x) => x.자재 ?? "").join(" · ") || "—",
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__name m02-hopyo__parts",
|
||||
children: [
|
||||
stateChip(line),
|
||||
...(parts.length ? partLines(line, parts, ctx) : []),
|
||||
small(`엑셀 ${line.이름}`.trim()),
|
||||
...nameNotes(line, parts, price),
|
||||
],
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__spec",
|
||||
text: p?.규격 ?? line.규격,
|
||||
attrs: { title: p?.규격 ?? line.규격 },
|
||||
}),
|
||||
qtyCell(line, parts),
|
||||
el("td", { className: "m02-labrow__unit", text: p?.단위 ?? line.단위 }),
|
||||
...markCells({ 별도계상: line.갈래 === "별도계상" ? "별도계상" : "직접계산" }),
|
||||
...PRICE_SLOTS.map((slot) =>
|
||||
el("td", {
|
||||
className: `m02-labrow__num m02-labrow__price${first?.기준칸 === slot ? " is-base-price" : ""}`,
|
||||
text: show(first?.가격?.[slot]),
|
||||
attrs: { "data-label": slot, "data-price-slot": slot },
|
||||
}),
|
||||
),
|
||||
pairCell(line.금액.계, price?.calc ? ours : null, wait, tc("Labor_AppliedSum")),
|
||||
]);
|
||||
}
|
||||
|
||||
const grid = (heads: string[], rows: HTMLElement[], kind: string): HTMLElement =>
|
||||
el("div", {
|
||||
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-labrow m02-hopyo__table m02-hopyo__lines",
|
||||
attrs: { "data-hopyo-table": kind },
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
/** 줄 표 둘 — 로직 표 · 자재 표(줄 없는 표는 안 그림) */
|
||||
export function checkTables(lines: HopyoLine[], ctx: RowsContext): HTMLElement[] {
|
||||
const logic = lines.filter((l) => tableOf(l, partsOf(l, ctx.d)) === "logic");
|
||||
const mat = lines.filter((l) => !logic.includes(l));
|
||||
const out: HTMLElement[] = [];
|
||||
if (logic.length)
|
||||
out.push(
|
||||
grid(
|
||||
[
|
||||
"",
|
||||
tc("Col_Order"),
|
||||
tc("Sub"),
|
||||
tc("Col_Name"),
|
||||
tc("Col_Alias"),
|
||||
tc("Labor_QtyCol"),
|
||||
tc("Col_Unit"),
|
||||
...COSTS.map((c) => tc(COST_HEAD[c])),
|
||||
"품 비교",
|
||||
"",
|
||||
],
|
||||
logic.map((l) => logicRow(l, ctx)),
|
||||
"logic",
|
||||
),
|
||||
);
|
||||
if (mat.length)
|
||||
out.push(
|
||||
el("h4", { className: "m02-hopyo__subhead", text: "자재" }),
|
||||
grid(
|
||||
[
|
||||
"",
|
||||
tc("Col_Order"),
|
||||
tc("Labor_Code"),
|
||||
tc("Col_Name"),
|
||||
tc("Labor_Spec"),
|
||||
tc("Labor_QtyCol"),
|
||||
tc("Labor_Unit"),
|
||||
...markHeads(),
|
||||
...PRICE_SLOTS,
|
||||
`엑셀 / 우리 ${tc("Labor_AppliedSum")}`,
|
||||
"",
|
||||
],
|
||||
mat.map((l) => matRow(l, ctx)),
|
||||
"mat",
|
||||
),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Choose.ts
|
||||
* 일위대가 실무자료 고르기 창(PLAN 338-3 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미.
|
||||
* · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀에서 보기](단가산출 그 행)
|
||||
* · 후보 다섯 = 이름 · 키 · 판정 꼬리표(맞음 / 다른 점 / 후보) · 글 · 근거 꼬리표(근거 맞음 초록) · 금액 비 없음
|
||||
* · 고름 = [고름](한 로직) · 또는 체크 + 몫 칸 → [고른 n개 함께](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움
|
||||
* · [다른 로직 찾기] = `openLogicPicker` 에 찾기 글 미리 넣음(엑셀 인용 조항 번호 → 없으면 품명 첫 뜻 낱말)
|
||||
* 일위대가 실무자료 고르기 창(PLAN 338-3 · 358 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미.
|
||||
* · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀 보기](단가산출 그 행)
|
||||
* · 후보 묶음(358-1 · 숨김 없음) = 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 324 다섯) / 숫자만 닮음(접힘 ·
|
||||
* 뜻 낱말 안 겹치는 「후보」는 회색 끝) · 같은 이름이면 책(산림 · 건설) · 판정 꼬리표 · 근거 꼬리표(근거 맞음 초록)
|
||||
* · 고름 = [후보](한 로직) · 또는 체크 + 몫 칸 → [일괄 적용(n)](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움
|
||||
* · [다른 로직 검색] = `openLogicPicker` 에 품명 첫 뜻 낱말 · [다른 자재 검색] = 자재 줄 · 재료비 있는 줄(358-2)
|
||||
* 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
HopyoBasisHit,
|
||||
HopyoLine,
|
||||
HopyoLineBasis,
|
||||
HopyoMatch,
|
||||
HopyoQty,
|
||||
HopyoTarget,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
@@ -95,13 +97,71 @@ export const qtyText = (q: HopyoQty | undefined): string =>
|
||||
].join(" · ")
|
||||
: "";
|
||||
|
||||
/** [다른 로직 찾기] 미리 넣을 글 — 엑셀 인용 조항 번호 → 없으면 품명 괄호 밖 첫 뜻 낱말 */
|
||||
export function searchHint(line: Pick<HopyoLine, "이름" | "근거">): string {
|
||||
const clause = line.근거?.출처?.find((x) => x[1])?.[1];
|
||||
if (clause) return clause;
|
||||
/** [다른 로직 검색] 미리 넣을 글 — 품명 괄호 밖 첫 뜻 낱말(358-1 · 조항 번호는 모든 낱말 맞춤이라 0건 위험) */
|
||||
export function searchHint(line: Pick<HopyoLine, "이름">): string {
|
||||
return /[가-힣A-Za-z]{2,}/.exec(line.이름.replace(/\([^()]*\)/g, " "))?.[0] ?? "";
|
||||
}
|
||||
|
||||
/** 후보 묶음 하나 — 제목 · 접힘(숫자만 닮음) · 후보 */
|
||||
export interface CandGroup {
|
||||
제목: string;
|
||||
접힘: boolean;
|
||||
후보: HopyoTarget[];
|
||||
}
|
||||
|
||||
/** 약한 후보 — 판정 「후보」 + 품명 뜻 낱말 안 겹침(회색 · 묶음 끝) */
|
||||
export const weakCand = (c: HopyoTarget): boolean => c.판정 === "후보" && c.뜻겹침 === false;
|
||||
|
||||
/** 후보 묶음(358-1 · 숨김 없음 · 차례는 서버 그대로) — 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 판 다섯) /
|
||||
* 숫자만 닮음(접힘 · 약한 후보 뒤로) · 한 후보는 한 묶음에만 · 자재 줄 = 1위 / 그 밖 후보 */
|
||||
export function candGroups(
|
||||
m: Pick<HopyoMatch, "후보" | "이름후보">,
|
||||
material = false,
|
||||
): CandGroup[] {
|
||||
const all = m.후보 ?? [];
|
||||
const seen = new Set<string>();
|
||||
const take = (list: HopyoTarget[]): HopyoTarget[] =>
|
||||
list.filter((c) => {
|
||||
const id = c.로직 ?? c.자재 ?? c.이름 ?? "";
|
||||
if (seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return true;
|
||||
});
|
||||
const top = take(all.slice(0, 1));
|
||||
if (material)
|
||||
return [
|
||||
{ 제목: "1위", 접힘: false, 후보: top },
|
||||
{ 제목: "그 밖 후보", 접힘: false, 후보: take(all.slice(1)) },
|
||||
].filter((g) => g.후보.length);
|
||||
const named = m.이름후보 ?? [];
|
||||
const source = take([...all.slice(1), ...named].filter((c) => c.출처같음));
|
||||
const name = take(named);
|
||||
const rest = take(all.slice(1));
|
||||
rest.sort((a, b) => Number(weakCand(a)) - Number(weakCand(b)));
|
||||
return [
|
||||
{ 제목: "1위", 접힘: false, 후보: top },
|
||||
{ 제목: "출처 같음", 접힘: false, 후보: source },
|
||||
{ 제목: "이름 닮은 로직", 접힘: false, 후보: name },
|
||||
{ 제목: "숫자만 닮음", 접힘: true, 후보: rest },
|
||||
].filter((g) => g.후보.length);
|
||||
}
|
||||
|
||||
/** 같은 이름 후보 — 책(산림 · 건설)을 붙여 가를 이름 */
|
||||
export function sameNames(groups: CandGroup[]): Set<string> {
|
||||
const count = new Map<string, number>();
|
||||
for (const c of groups.flatMap((g) => g.후보))
|
||||
count.set(c.이름 ?? "", (count.get(c.이름 ?? "") ?? 0) + 1);
|
||||
return new Set([...count].filter(([name, n]) => name && n > 1).map(([name]) => name));
|
||||
}
|
||||
|
||||
/** 후보 단추 글 — 「이름 [단위] · 키」 · 같은 이름이면 책 두 글자(산림 · 건설) */
|
||||
export const candText = (c: HopyoTarget, twins: Set<string>): string =>
|
||||
`${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}${twins.has(c.이름 ?? "") && c.책 ? ` · ${c.책.slice(0, 2)}` : ""}`;
|
||||
|
||||
/** 자재 고름 — 자재 · 별도계상 줄 · 재료비 있는 단가산출 줄(358-2 · P.V.C파이프설치 등) */
|
||||
export const canPickMaterial = (line: Pick<HopyoLine, "갈래" | "단가">): boolean =>
|
||||
MATERIAL.has(line.갈래) || !!line.단가?.재료비;
|
||||
|
||||
/** 고른 후보 → 만들기 대상(배 1 · 몫 있으면 배 = 몫) */
|
||||
export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): BuildTarget[] =>
|
||||
picked.map(({ c, 몫 }) => ({
|
||||
@@ -113,12 +173,12 @@ export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]):
|
||||
|
||||
export interface ChooseOptions {
|
||||
line: HopyoLine;
|
||||
/** [엑셀에서 보기] — 단가산출 그 행 */
|
||||
/** [엑셀 보기] — 단가산출 그 행 */
|
||||
openSheet?: (행: [number, number]) => void;
|
||||
done: (targets: BuildTarget[]) => void;
|
||||
}
|
||||
|
||||
/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 다섯(하나 또는 여럿 · 몫) · 다른 로직 · 자재 찾기 */
|
||||
/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 묶음(하나 또는 여럿 · 몫) · 다른 로직 · 자재 검색 */
|
||||
export function openHopyoChoose(opt: ChooseOptions): void {
|
||||
const { line } = opt;
|
||||
const material = MATERIAL.has(line.갈래);
|
||||
@@ -132,9 +192,9 @@ export function openHopyoChoose(opt: ChooseOptions): void {
|
||||
opt.done(targets);
|
||||
};
|
||||
const picked = new Map<HopyoTarget, HTMLInputElement>();
|
||||
const together = createButton({ label: "고른 0개 함께", size: "sm", disabled: true });
|
||||
const together = createButton({ label: "일괄 적용(0)", size: "sm", disabled: true });
|
||||
const paint = (): void => {
|
||||
together.querySelector(".ui-btn__label")!.textContent = `고른 ${picked.size}개 함께`;
|
||||
together.querySelector(".ui-btn__label")!.textContent = `일괄 적용(${picked.size})`;
|
||||
together.disabled = picked.size < 2;
|
||||
};
|
||||
together.addEventListener("click", () =>
|
||||
@@ -144,7 +204,9 @@ export function openHopyoChoose(opt: ChooseOptions): void {
|
||||
),
|
||||
),
|
||||
);
|
||||
const list = (line.맞춤.후보 ?? []).map((c) => {
|
||||
const groups = candGroups(line.맞춤, material);
|
||||
const twins = sameNames(groups);
|
||||
const row = (c: HopyoTarget): HTMLElement => {
|
||||
const share = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__times",
|
||||
attrs: { type: "number", step: "0.05", min: "0", max: "1", placeholder: "몫" },
|
||||
@@ -157,12 +219,12 @@ export function openHopyoChoose(opt: ChooseOptions): void {
|
||||
});
|
||||
const tag = basisTag(c.근거);
|
||||
return el("div", {
|
||||
className: "m02-hopyo__cand",
|
||||
className: `m02-hopyo__cand${weakCand(c) ? " is-weak" : ""}`,
|
||||
attrs: { "data-cand": c.로직 ?? c.자재 ?? "" },
|
||||
children: [
|
||||
box,
|
||||
createButton({
|
||||
label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`,
|
||||
label: candText(c, twins),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => finish(pickedTargets([{ c, 몫: null }])),
|
||||
@@ -176,6 +238,7 @@ export function openHopyoChoose(opt: ChooseOptions): void {
|
||||
]
|
||||
: []),
|
||||
...(c.글 ? [note(c.글)] : []),
|
||||
...(weakCand(c) ? [note("이름 뜻 안 겹침")] : []),
|
||||
...(tag && c.근거
|
||||
? [
|
||||
el("span", {
|
||||
@@ -188,6 +251,18 @@ export function openHopyoChoose(opt: ChooseOptions): void {
|
||||
share,
|
||||
],
|
||||
});
|
||||
};
|
||||
const sections = groups.map((g) => {
|
||||
const list = el("div", { className: "m02-hopyo__cands", children: g.후보.map(row) });
|
||||
const title = `${g.제목} (${g.후보.length})`;
|
||||
if (!g.접힘)
|
||||
return el("div", { attrs: { "data-group": g.제목 }, children: [note(title), list] });
|
||||
const fold = el("details", {
|
||||
className: "m02-hopyo__group",
|
||||
attrs: { "data-group": g.제목 },
|
||||
children: [el("summary", { text: `${title} — 이름 · 출처 안 닮음 · 숫자 지문만` }), list],
|
||||
});
|
||||
return fold;
|
||||
});
|
||||
const excel = qtyText(line.맞춤.품?.엑셀);
|
||||
const basis = line.근거;
|
||||
@@ -199,7 +274,7 @@ export function openHopyoChoose(opt: ChooseOptions): void {
|
||||
...(basis && opt.openSheet
|
||||
? [
|
||||
createButton({
|
||||
label: "엑셀에서 보기",
|
||||
label: "엑셀 보기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => opt.openSheet?.(basis.행),
|
||||
@@ -208,23 +283,37 @@ export function openHopyoChoose(opt: ChooseOptions): void {
|
||||
: []),
|
||||
],
|
||||
});
|
||||
const more = createButton({
|
||||
label: material ? "다른 자재 찾기…" : "다른 로직 찾기…",
|
||||
const findMaterial = createButton({
|
||||
label: "다른 자재 검색…",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
close();
|
||||
if (material) pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }]));
|
||||
else
|
||||
openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, {
|
||||
query: searchHint(line),
|
||||
});
|
||||
pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }]));
|
||||
},
|
||||
});
|
||||
const findLogic = createButton({
|
||||
label: "다른 로직 검색…",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
close();
|
||||
openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, {
|
||||
query: searchHint(line),
|
||||
});
|
||||
},
|
||||
});
|
||||
const more = [
|
||||
...(material ? [] : [findLogic]),
|
||||
...(canPickMaterial(line) ? [findMaterial] : []),
|
||||
];
|
||||
body.append(
|
||||
...(head.childElementCount ? [head] : []),
|
||||
note(list.length ? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [함께]" : "가까운 것 없음"),
|
||||
el("div", { className: "m02-hopyo__cands", children: list }),
|
||||
el("div", { className: "m02-hopyo__bar", children: [together, more] }),
|
||||
note(
|
||||
groups.length
|
||||
? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [일괄 적용]"
|
||||
: "가까운 것 없음 — 아래 검색",
|
||||
),
|
||||
...sections,
|
||||
el("div", { className: "m02-hopyo__bar", children: [together, ...more] }),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* M02_MasterTemplete_Hopyo_Ledger.ts
|
||||
* 일위대가 실무자료 별칭 장부 화면(PLAN 338-3 · 설계 4-4) — 본 칸 · 좌측 판 [별칭 장부] 로 엶.
|
||||
* · 표 = 엑셀 품명 · 규격 · 단위 | 갈래 | 우리 로직(여럿) · 옵션 · 배 · 몫 | 누가 · 때 · 처음 파일 | 층(위 층 회색 읽기만)
|
||||
* · 할 일 = 찾기 · 갈래 거름 · 갈래 바꿈 · [로직 바꿈] · [로직 더함] · 배 · 몫 · [옵션] · [지우기] ·
|
||||
* 위 층 줄 [내 층으로 고쳐 씀]
|
||||
* · 뜻(359-4 · 머리 한 줄 · 좌측 단추 title) = 엑셀 품명 ↔ 우리 로직 · 자재 짝을 기억해 두었다가 같은 품명이 나오면 먼저 제안
|
||||
* · 할 일 = 찾기 · 갈래 거름 · 갈래 바꿈 · [로직 바꿈] · [로직 추가] · 배 · 몫 · [옵션] · [삭제] ·
|
||||
* 위 층 줄 [내 층 복사] · 갈래 글 = 확인 창 드롭다운과 같음(짝 · 로직없음 · 사용안함 · 저장 값 그대로)
|
||||
* · 고친 것 = sessionStorage `m02-hopyo-ledger:{층}:{주인}` → [저장] 때만 `PUT`(서버가 짝을 다시 검사)
|
||||
* · 안 둠 = 발주처 칸 · 자동 올림 · 자동 합침 · 쓰인 횟수 · 회사로 올리기.
|
||||
* ========================================================================== */
|
||||
@@ -24,7 +25,16 @@ import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export const LEDGER_TITLE = "별칭 장부";
|
||||
/** 별칭 장부 뜻 한 줄(359-4) — 좌측 단추 title · 화면 머리 */
|
||||
export const LEDGER_HINT =
|
||||
"엑셀 품명 ↔ 우리 로직 · 자재 짝을 기억해 두었다가 같은 품명이 나오면 먼저 제안";
|
||||
const KINDS = ["짝", "우리에 없음", "안 씀"] as const;
|
||||
/** 갈래 화면 글 — 확인 창 드롭다운과 같음(저장 값 그대로) */
|
||||
export const KIND_TEXT: Record<string, string> = {
|
||||
짝: "짝",
|
||||
"우리에 없음": "로직없음",
|
||||
"안 씀": "사용안함",
|
||||
};
|
||||
const LAYER_TEXT: Record<string, string> = { system: "마스터", company: "회사", personal: "개인" };
|
||||
type Edits = Record<string, LedgerEntry | null>;
|
||||
/** 화면 한 줄 — 서버 줄 위에 고친 것 */
|
||||
@@ -136,11 +146,11 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
|
||||
className: "ui-input ui-input--sm",
|
||||
children: [
|
||||
el("option", { text: "갈래 전체", attrs: { value: "" } }),
|
||||
...KINDS.map((x) => el("option", { text: x, attrs: { value: x } })),
|
||||
...KINDS.map((x) => el("option", { text: KIND_TEXT[x], attrs: { value: x } })),
|
||||
],
|
||||
}) as HTMLSelectElement;
|
||||
const save = createButton({ label: "저장", variant: "filled", size: "sm" });
|
||||
const drop = createButton({ label: "고친 것 버림", variant: "ghost", size: "sm" });
|
||||
const drop = createButton({ label: "변경 취소", variant: "ghost", size: "sm" });
|
||||
const body = el("tbody");
|
||||
const keep = (): void => {
|
||||
saveEdits(k, edits);
|
||||
@@ -199,7 +209,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
|
||||
if (mine && r.갈래 === "짝")
|
||||
pairs.push(
|
||||
createButton({
|
||||
label: "로직 더함",
|
||||
label: "로직 추가",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
@@ -217,7 +227,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
|
||||
? (() => {
|
||||
const s = el("select", {
|
||||
className: "ui-input ui-input--sm",
|
||||
children: KINDS.map((x) => el("option", { text: x, attrs: { value: x } })),
|
||||
children: KINDS.map((x) => el("option", { text: KIND_TEXT[x], attrs: { value: x } })),
|
||||
}) as HTMLSelectElement;
|
||||
s.value = r.갈래;
|
||||
s.addEventListener("change", () =>
|
||||
@@ -225,12 +235,12 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
|
||||
);
|
||||
return el("td", { children: [s] });
|
||||
})()
|
||||
: td(r.갈래);
|
||||
: td(KIND_TEXT[r.갈래] ?? r.갈래);
|
||||
const acts: HTMLElement[] = [];
|
||||
if (mine)
|
||||
acts.push(
|
||||
createButton({
|
||||
label: "지우기",
|
||||
label: "삭제",
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
@@ -242,7 +252,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
|
||||
else if (opt.canWrite && !rows.some((x) => x.고침 && x.열쇠 === r.열쇠) && !edits[r.열쇠])
|
||||
acts.push(
|
||||
createButton({
|
||||
label: "내 층으로 고쳐 씀",
|
||||
label: "내 층 복사",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
@@ -316,7 +326,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [
|
||||
el("strong", { text: LEDGER_TITLE }),
|
||||
note("엑셀 이름 → 우리 로직 + 옵션 + 배 · 확인하고 만들 때 남음 · 아래 층이 이김"),
|
||||
note(`${LEDGER_HINT} · 사전조합 생성 때 남음 · 아래 층이 이김`),
|
||||
search,
|
||||
kind,
|
||||
...(opt.canWrite ? [save, drop] : []),
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Sheet.ts
|
||||
* 일위대가 실무자료 엑셀 보기 창(PLAN 329 · 338-3) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거
|
||||
* 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 제안 n/m — 뒤 셈 중이면 3 초마다 다시)
|
||||
* + [고른 n개 확인하고 만들기].
|
||||
* 일위대가 실무자료 엑셀 보기 창(PLAN 329 · 338-3 · 359-1 · 359-2) — 큰 모달 · 머리 막대 = 시트 · 호표 수 · 검산 ·
|
||||
* 제안 n / 줄 m · 숫자 판정 셈 중 · [삭제](좌측 파일 줄 누름으로 바로 엶 · 옛 본 칸 표 몫) ·
|
||||
* 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 스프레드시트 · 탭 일위대가 · 단가산출) ·
|
||||
* 오른쪽 = 호표 목록(체크 · 제N호표 [수정] · 찾기 · 제안 n/m — 다 연결이면 녹색 · 뒤 셈 중이면 3 초마다 다시) + [저장].
|
||||
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름
|
||||
* · ① [고른 n개 확인하고 만들기] = 고른 호표마다 서버 보기 → 펼침 표(넣을 줄 = 체크 제안 · 빠질 줄 · 셈 중 또는 맞음 · 다른 점 수 ·
|
||||
* [확인 창 열기]) ② [이대로 n개 만들기] = 하위 호표부터 차례로 `/hopyo-build`(몸 = 그 호표 체크 줄) —
|
||||
* 펼쳐 보이고 한 번 누름 = 사용자 확인 · 결과 표(사전조합 키 · 빠진 줄 · 값 차이) ·
|
||||
* `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄)
|
||||
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 확인하고 만들기는 됨
|
||||
* · [수정] = 그 호표 확인 창(`openHopyoCheck` · 줄 누름과 따로)
|
||||
* · [저장] = 체크한 호표마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로 하위 호표부터 차례로 `/hopyo-build` —
|
||||
* 물음 한 번(확인 창 안 연 호표 수) · 체크 줄 없는 호표 건너뜀 · 하위 확인 전(409)은 안내 ·
|
||||
* 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄)
|
||||
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 저장은 됨
|
||||
* ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -16,14 +17,17 @@ import {
|
||||
createButton,
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
buildHopyo,
|
||||
deleteHopyo,
|
||||
readHopyo,
|
||||
readHopyoGrid,
|
||||
readHopyoOne,
|
||||
@@ -38,8 +42,8 @@ import {
|
||||
import {
|
||||
bodyOf,
|
||||
draftKey,
|
||||
hasDraft,
|
||||
HOPYO_BUILT,
|
||||
leftOut,
|
||||
loadDraft,
|
||||
openHopyoCheck,
|
||||
partsOf,
|
||||
@@ -58,12 +62,16 @@ export interface HopyoSheetOptions {
|
||||
canWrite: boolean;
|
||||
/** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */
|
||||
target?: HopyoScope;
|
||||
/** 처음 고를 호표 번(호표 화면 [엑셀에서 보기]) */
|
||||
/** 처음 고를 호표 번(확인 창 머리 [엑셀 보기]) */
|
||||
호표?: number;
|
||||
/** 처음 보일 단가산출 행(330-3 근거 [엑셀에서 보기]) */
|
||||
/** 처음 보일 단가산출 행(330-3 근거 [엑셀 보기]) */
|
||||
단산행?: number | [number, number];
|
||||
/** 호표 하나를 만들었음 — [로직 추가] 참조 줄 */
|
||||
onImport?: (got: HopyoBuilt, hopyo: HopyoBrief) => void;
|
||||
/** 창을 닫았음(좌측 파일 줄 고름 표시) */
|
||||
onClose?: () => void;
|
||||
/** [삭제] 했음 — 좌측 목록 다시 · 없으면 [삭제] 단추 없음 */
|
||||
onDeleted?: () => void;
|
||||
}
|
||||
|
||||
const ILWI = "일위대가";
|
||||
@@ -74,6 +82,21 @@ const won = (v: number | null | undefined): string =>
|
||||
v === null || v === undefined ? "" : v.toLocaleString("ko-KR");
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
|
||||
/** 목록 숫자 「연결된 줄 / 연결할 줄」(358-3 두 칸 · 없으면 옛 맞춤 [제안, 줄]) — 다 연결(같고 0 넘음)이면 참 ·
|
||||
* 0/0 은 색 없음 */
|
||||
export function allLinked(h: {
|
||||
맞춤: [number, number];
|
||||
연결할?: number;
|
||||
연결된?: number;
|
||||
}): boolean {
|
||||
const [done, all] = h.연결할 === undefined ? h.맞춤 : [h.연결된 ?? 0, h.연결할];
|
||||
return all > 0 && done === all;
|
||||
}
|
||||
|
||||
/** 목록 숫자 글 — 358-3 두 칸이 있으면 그것 */
|
||||
const linkText = (h: { 맞춤: [number, number]; 연결할?: number; 연결된?: number }): string =>
|
||||
h.연결할 === undefined ? `${h.맞춤[0]}/${h.맞춤[1]}` : `${h.연결된 ?? 0}/${h.연결할}`;
|
||||
|
||||
/** 엑셀 행(1부터)이 든 호표 번 · 없으면 null */
|
||||
export const hopyoAtRow = (list: HopyoSpan[], row: number): number | null =>
|
||||
list.find((h) => h.행[0] <= row && row <= h.행[1])?.번 ?? null;
|
||||
@@ -119,7 +142,11 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
let current: number | null = opt.호표 ?? null;
|
||||
const checked = new Set<number>();
|
||||
const items = new Map<number, HTMLElement>();
|
||||
const target = opt.target ?? opt.scope;
|
||||
|
||||
const head = el("div", { className: "m02-hopyo__bar", children: [note("엑셀을 읽는 중…")] });
|
||||
const warn = el("p", { className: "m02-hopyo__warn" });
|
||||
warn.hidden = true;
|
||||
const left = el("div", {
|
||||
className: "m02-hopyo-sheet__grid",
|
||||
children: [note("엑셀을 읽는 중…")],
|
||||
@@ -130,8 +157,9 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
}) as HTMLInputElement;
|
||||
const list = el("div", { className: "m02-hopyo-sheet__list" });
|
||||
const count = note("");
|
||||
const go = createButton({ label: "고른 0개 확인하고 만들기", variant: "filled", size: "sm" });
|
||||
const go = createButton({ label: "저장", variant: "filled", size: "sm" });
|
||||
go.disabled = true;
|
||||
go.setAttribute("data-hopyo-save", "");
|
||||
const result = el("div", { className: "m02-hopyo-sheet__result" });
|
||||
const right = el("div", {
|
||||
className: "m02-hopyo-sheet__side",
|
||||
@@ -143,25 +171,70 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
],
|
||||
});
|
||||
|
||||
openModal({
|
||||
const finish = (): void => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
sheet?.destroy();
|
||||
opt.onClose?.();
|
||||
};
|
||||
const modal = openModal({
|
||||
title: `엑셀 보기 — ${opt.파일}`,
|
||||
closeLabel: "닫기",
|
||||
wide: true,
|
||||
dialogClass: "m02-modal m02-hopyo-sheet__dialog",
|
||||
beforeClose: async () => {
|
||||
closed = true;
|
||||
sheet?.destroy();
|
||||
finish();
|
||||
return true;
|
||||
},
|
||||
mount: (body) =>
|
||||
body.append(el("div", { className: "m02-hopyo-sheet", children: [left, right] })),
|
||||
body.append(head, warn, el("div", { className: "m02-hopyo-sheet", children: [left, right] })),
|
||||
});
|
||||
|
||||
const paintGo = (): void => {
|
||||
go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 확인하고 만들기`;
|
||||
go.querySelector(".ui-btn__label")!.textContent = checked.size
|
||||
? `저장 (${checked.size})`
|
||||
: "저장";
|
||||
go.disabled = !checked.size;
|
||||
};
|
||||
|
||||
/** 머리 막대 — 옛 본 칸 표 몫(시트 · 호표 수 · 검산 · 제안 · 셈 중 · [삭제]) */
|
||||
function paintHead(file: HopyoView): void {
|
||||
const wrong = file.검산.어긋남;
|
||||
const c = file.셈 && !file.셈.끝 ? file.셈 : null;
|
||||
head.replaceChildren(
|
||||
note(`시트 ${file.시트}`),
|
||||
note(`호표 ${file.호표수}`),
|
||||
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
|
||||
note(`제안 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`),
|
||||
...(c
|
||||
? [note(`숫자 판정 셈 중 ${c.한}/${c.모두 ?? "?"}${c.까닭 ? ` · ${c.까닭}` : ""}`)]
|
||||
: []),
|
||||
...(opt.canWrite && opt.onDeleted ? [dropButton()] : []),
|
||||
);
|
||||
warn.textContent = wrong.join("\n");
|
||||
warn.hidden = !wrong.length;
|
||||
}
|
||||
|
||||
const dropButton = (): HTMLButtonElement =>
|
||||
createButton({
|
||||
label: "삭제",
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: async () => {
|
||||
const ask = `실무자료 「${opt.파일}」을 지움 — 만든 사전조합 · 별칭 장부는 그대로`;
|
||||
if (!(await showConfirmDialog(ask, "삭제"))) return;
|
||||
try {
|
||||
await deleteHopyo(opt.scope, opt.key);
|
||||
showToast("지움", "success");
|
||||
finish();
|
||||
modal.close();
|
||||
opt.onDeleted?.();
|
||||
} catch (error) {
|
||||
showToast(`실패 — ${why(error)}`, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 목록 강조 · 보이게(시트 고름은 그대로) */
|
||||
function mark(no: number | null): void {
|
||||
items.get(current ?? NaN)?.classList.remove("is-current");
|
||||
@@ -180,6 +253,28 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
if (at.시트 !== ilwi || at.범위 !== text) sheet.select(text, ilwi);
|
||||
}
|
||||
|
||||
/** [수정] — 그 호표 확인 창(줄 누름과 따로) */
|
||||
const editButton = (h: HopyoBrief): HTMLElement => {
|
||||
const b = createButton({
|
||||
label: "수정",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
openHopyoCheck({
|
||||
scope: opt.scope,
|
||||
key: opt.key,
|
||||
no: h.번,
|
||||
target,
|
||||
canWrite: opt.canWrite,
|
||||
onBuilt: (got) => opt.onImport?.(got, h),
|
||||
});
|
||||
},
|
||||
});
|
||||
b.setAttribute("data-hopyo-edit", String(h.번));
|
||||
return b;
|
||||
};
|
||||
|
||||
function paintList(): void {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
items.clear();
|
||||
@@ -197,14 +292,17 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
else checked.delete(h.번);
|
||||
paintGo();
|
||||
});
|
||||
const linked = note(linkText(h));
|
||||
linked.classList.toggle("is-linked", allLinked(h));
|
||||
const row = el("div", {
|
||||
className: `m02-hopyo-sheet__item${h.번 === current ? " is-current" : ""}`,
|
||||
attrs: { "data-hopyo-no": String(h.번), title: `${h.이름} ${h.규격}`.trim() },
|
||||
children: [
|
||||
box,
|
||||
el("span", { className: "m02-hopyo-sheet__no", text: `제${h.번}호표` }),
|
||||
editButton(h),
|
||||
el("span", { className: "m02-hopyo-sheet__name", text: `${h.이름} ${h.규격}`.trim() }),
|
||||
note(`${h.맞춤[0]}/${h.맞춤[1]}`),
|
||||
linked,
|
||||
],
|
||||
});
|
||||
row.addEventListener("click", () => focus(h.번));
|
||||
@@ -243,13 +341,14 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** 뒤 숫자 판정 셈 중이면 3 초 뒤 목록 「제안 n/m」 다시 읽음 — 셈 끝 · 창 닫음 · 못 읽음이면 멈춤 */
|
||||
/** 뒤 숫자 판정 셈 중이면 3 초 뒤 목록 「제안 n/m」 · 머리 다시 읽음 — 셈 끝 · 창 닫음 · 못 읽음이면 멈춤 */
|
||||
function poll(file: HopyoView): void {
|
||||
if (!file.셈 || file.셈.끝) return;
|
||||
window.setTimeout(async () => {
|
||||
const next = closed ? null : await readHopyo(opt.scope, opt.key).catch(() => null);
|
||||
if (!next || closed) return;
|
||||
briefs = next.호표;
|
||||
paintHead(next);
|
||||
paintList();
|
||||
poll(next);
|
||||
}, POLL_MS);
|
||||
@@ -264,6 +363,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
if (closed) return;
|
||||
briefs = file.호표;
|
||||
spans = grid instanceof Error ? [] : grid.호표;
|
||||
paintHead(file);
|
||||
paintList();
|
||||
poll(file);
|
||||
if (grid instanceof Error) return void left.replaceChildren(note(grid.message));
|
||||
@@ -271,7 +371,10 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
if (opt.단산행 !== undefined) sheet?.select(rowsText(opt.단산행), DANSAN);
|
||||
else if (current !== null) focus(current);
|
||||
} catch (error) {
|
||||
if (!closed) left.replaceChildren(note(`못 읽음 — ${why(error)}`));
|
||||
if (!closed) {
|
||||
head.replaceChildren(note(`못 읽음 — ${why(error)}`));
|
||||
left.replaceChildren(note(`못 읽음 — ${why(error)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,84 +400,50 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
|
||||
return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null;
|
||||
}
|
||||
const headCell = (no: number, name: string): HTMLElement =>
|
||||
el("td", { text: `제${no}호표 ${name}`.trim() });
|
||||
|
||||
/** ① 고른 호표마다 서버 보기 → 펼침 표 */
|
||||
async function review(): Promise<void> {
|
||||
const target = opt.target ?? opt.scope;
|
||||
/** [저장] — 물음 한 번 → 고른 호표마다 서버 보기 → 체크 줄 있는 것만 만들기(체크 줄 없으면 건너뜀) */
|
||||
async function save(): Promise<void> {
|
||||
const nos = [...checked].sort((a, b) => a - b);
|
||||
const fresh = nos.filter((no) => !hasDraft(draftKey(opt.scope, opt.key, no))).length;
|
||||
const ask =
|
||||
`고른 호표 ${nos.length}개를 체크한 줄로 사전조합 생성` +
|
||||
(fresh ? ` — 확인 창에서 안 고친 ${fresh}개는 서버 제안 체크 줄` : "");
|
||||
if (!(await showConfirmDialog(ask, "저장"))) return;
|
||||
go.disabled = true;
|
||||
const ones: HopyoOne[] = [];
|
||||
for (const no of nos) {
|
||||
if (closed) return;
|
||||
try {
|
||||
ones.push(await readHopyoOne(opt.scope, opt.key, no, target));
|
||||
} catch (error) {
|
||||
showToast(`제${no}호표 — ${why(error)}`, "error");
|
||||
const skipped: HTMLElement[] = [];
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
for (const no of nos) {
|
||||
if (closed) return;
|
||||
try {
|
||||
ones.push(await readHopyoOne(opt.scope, opt.key, no, target));
|
||||
} catch (error) {
|
||||
skipped.push(el("tr", { children: [headCell(no, ""), el("td", { text: why(error) })] }));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
paintGo();
|
||||
}
|
||||
paintGo();
|
||||
const rows = ones.map((one) => {
|
||||
const h = one.호표;
|
||||
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
|
||||
const out = Object.entries(leftOut(h.줄, d));
|
||||
const tally = (s: string): number => h.줄.filter((l) => l.맞춤.상태 === s).length;
|
||||
const calc =
|
||||
h.셈 && !h.셈.끝
|
||||
? `셈 중 ${h.셈.한}/${h.셈.모두}`
|
||||
: `맞음 ${tally("맞음")} · 다른 점 ${tally("다른 점")}`;
|
||||
const brief = briefs.find((x) => x.번 === h.번) ?? h;
|
||||
return el("tr", {
|
||||
attrs: { "data-review": String(h.번) },
|
||||
children: [
|
||||
el("td", { text: `제${h.번}호표 ${h.이름}`.trim() }),
|
||||
el("td", {
|
||||
text: wholeFor(one) ? "통째" : String(bodyFor(one).filter((x) => x.대상).length),
|
||||
className: "m02-hopyo__num",
|
||||
}),
|
||||
el("td", { text: out.map(([a, b]) => `${a} ${b}`).join(" · ") }),
|
||||
el("td", { text: calc }),
|
||||
el("td", {
|
||||
const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one));
|
||||
for (const one of ones)
|
||||
if (!ready.includes(one))
|
||||
skipped.push(
|
||||
el("tr", {
|
||||
children: [
|
||||
createButton({
|
||||
label: "확인 창 열기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openHopyoCheck({
|
||||
scope: opt.scope,
|
||||
key: opt.key,
|
||||
no: h.번,
|
||||
target,
|
||||
canWrite: opt.canWrite,
|
||||
onBuilt: (got) => opt.onImport?.(got, brief),
|
||||
}),
|
||||
}),
|
||||
headCell(one.호표.번, one.호표.이름),
|
||||
el("td", { text: "건너뜀 — 체크 줄 없음" }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one));
|
||||
const all = createButton({
|
||||
label: `이대로 ${ready.length}개 만들기`,
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
disabled: !ready.length,
|
||||
onClick: () => void makeAll(ready),
|
||||
});
|
||||
all.setAttribute("data-hopyo-make-all", "");
|
||||
result.replaceChildren(
|
||||
table(["호표", "넣을 줄", "빠질 줄", "숫자 판정", ""], rows),
|
||||
el("div", {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [all, note("넣을 줄 = 체크 제안 · 확인 창에서 고친 것")],
|
||||
}),
|
||||
);
|
||||
);
|
||||
await makeAll(ready, skipped);
|
||||
}
|
||||
|
||||
/** ② 한 번 누름 = 사용자 확인 — 하위 호표부터 차례로 만들기 */
|
||||
async function makeAll(ones: HopyoOne[]): Promise<void> {
|
||||
const target = opt.target ?? opt.scope;
|
||||
/** 하위 호표부터 차례로 만들기 — 하위 확인 전(409)은 안내 */
|
||||
async function makeAll(ones: HopyoOne[], skipped: HTMLElement[]): Promise<void> {
|
||||
const by = new Map(ones.map((one) => [one.호표.번, one]));
|
||||
const order = buildOrder(
|
||||
ones.map((one) => {
|
||||
@@ -392,7 +461,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
for (const no of order) {
|
||||
const one = by.get(no)!;
|
||||
const h = briefs.find((x) => x.번 === no) ?? one.호표;
|
||||
const head = el("td", { text: `제${no}호표 ${h.이름}`.trim() });
|
||||
const cell = headCell(no, h.이름);
|
||||
try {
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no };
|
||||
const got = await buildHopyo(target, from, bodyFor(one), wholeFor(one));
|
||||
@@ -402,7 +471,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
el("tr", {
|
||||
attrs: { "data-built": got.참조.키 },
|
||||
children: [
|
||||
head,
|
||||
cell,
|
||||
el("td", { text: `${got.참조.키} · ${builtWord(got)}` }),
|
||||
el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }),
|
||||
el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }),
|
||||
@@ -410,17 +479,24 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] }));
|
||||
const text =
|
||||
error instanceof StaleError
|
||||
? `안내 — ${why(error)} · 하위 호표를 함께 고르거나 먼저 저장`
|
||||
: `실패 — ${why(error)}`;
|
||||
rows.push(el("tr", { children: [cell, el("td", { text })] }));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
if (ok) document.dispatchEvent(new Event(HOPYO_BUILT));
|
||||
result.replaceChildren(table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], rows));
|
||||
showToast(`만듦 ${ok} / ${order.length}`, ok === order.length ? "success" : "warning");
|
||||
result.replaceChildren(
|
||||
table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], [...rows, ...skipped]),
|
||||
);
|
||||
const all = order.length + skipped.length;
|
||||
showToast(`만듦 ${ok} / ${all}`, ok === all ? "success" : "warning");
|
||||
}
|
||||
go.addEventListener("click", () => void review());
|
||||
go.addEventListener("click", () => void save());
|
||||
|
||||
void load();
|
||||
}
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Side.ts
|
||||
* 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) ·
|
||||
* [엑셀 올리기](숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + 파일 줄 → 누르면 본 화면
|
||||
* (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만 ·
|
||||
* 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶 ·
|
||||
* 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 359-1 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) ·
|
||||
* [파일 업로드](구조물 조합 층 단추 꼴 `m02-layers__btn` · 숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) +
|
||||
* 파일 줄 → 누르면 바로 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴 · 지우기 · 검산 · 셈 중은 창 머리) ·
|
||||
* 고름 표시 = 그 파일 창이 열린 동안 · 올린 직후도 엑셀 보기 창 ·
|
||||
* 파일 목록 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { listHopyo, pageScope, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { buildHopyoLedger, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger";
|
||||
import { buildHopyoView } from "./M02_MasterTemplete_Hopyo_View";
|
||||
import { buildHopyoLedger, LEDGER_HINT, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger";
|
||||
|
||||
export const HOPYO_TITLE = "일위대가 실무자료";
|
||||
|
||||
export interface HopyoSectionOptions {
|
||||
/** 이 층 쓰기 권한 — 없으면 올리기 꺼짐 · 본 화면 확정 · 만들기 없음 */
|
||||
/** 이 층 쓰기 권한 — 없으면 올리기 꺼짐 · 엑셀 보기 창 보기만 */
|
||||
canWrite: boolean;
|
||||
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
@@ -42,22 +41,19 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
attrs: { type: "file", accept: ".xls,.xlsx", "data-hopyo-file": "" },
|
||||
}) as HTMLInputElement;
|
||||
input.hidden = true;
|
||||
// 구조물 조합 층 단추(`TreePanel_Roots` [마스터])와 같은 꼴 — 보통 크기 · 같은 클래스
|
||||
const upload = createButton({
|
||||
label: "엑셀 올리기",
|
||||
label: "파일 업로드",
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
disabled: !opt.canWrite,
|
||||
onClick: () => input.click(),
|
||||
});
|
||||
upload.classList.add("m02-layers__btn");
|
||||
upload.title = opt.canWrite ? "설계내역서 엑셀(일위대가 시트)" : "이 층 쓰기 권한 없음";
|
||||
const ledger = el("button", {
|
||||
className: "m02-side__item ui-sidebar-row",
|
||||
text: LEDGER_TITLE,
|
||||
attrs: {
|
||||
type: "button",
|
||||
title: "엑셀 이름 → 우리 로직 짝 보기 · 고치기",
|
||||
"data-hopyo-ledger-open": "",
|
||||
},
|
||||
attrs: { type: "button", title: LEDGER_HINT, "data-hopyo-ledger-open": "" },
|
||||
});
|
||||
ledger.addEventListener("click", () => void showLedger());
|
||||
const body = el("div", {
|
||||
@@ -66,6 +62,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
});
|
||||
let files: HopyoFile[] = [];
|
||||
let open: { key: string; view: HTMLElement } | null = null;
|
||||
let sheetKey: string | null = null; // 엑셀 보기 창이 열린 파일
|
||||
let seq = 0;
|
||||
|
||||
input.addEventListener("change", () => {
|
||||
@@ -78,9 +75,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
const wrong = got.어긋남 ? ` · 검산 어긋남 ${got.어긋남}` : "";
|
||||
showToast(`올림 — 호표 ${got.호표수}${wrong}`, got.어긋남 ? "warning" : "success");
|
||||
await refresh();
|
||||
await show(got);
|
||||
const sheet = await import("./M02_MasterTemplete_Hopyo_Sheet");
|
||||
sheet.openHopyoSheet({ scope, key: got.키, 파일: got.파일, canWrite: opt.canWrite });
|
||||
await showSheet(got);
|
||||
})
|
||||
.catch((error) => showToast(why(error), "error"))
|
||||
.finally(() => (upload.disabled = !opt.canWrite));
|
||||
@@ -92,32 +87,37 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
...(files.length
|
||||
? files.map((f) => {
|
||||
const row = el("button", {
|
||||
className: `m02-side__item ui-sidebar-row${open?.key === f.키 ? " is-active" : ""}`,
|
||||
className: `m02-side__item ui-sidebar-row${sheetKey === f.키 ? " is-active" : ""}`,
|
||||
text: `${f.파일} · 호표 ${f.호표수}`,
|
||||
attrs: { type: "button", title: `${f.키} · ${f.올린때}`, "data-hopyo-key": f.키 },
|
||||
});
|
||||
row.addEventListener("click", () => void show(f));
|
||||
row.addEventListener("click", () => void showSheet(f));
|
||||
return row;
|
||||
})
|
||||
: [el("p", { className: "m02-side__empty", text: "올린 실무자료 없음" })]),
|
||||
);
|
||||
}
|
||||
|
||||
async function show(file: HopyoFile): Promise<void> {
|
||||
if (open?.key === file.키 && open.view.isConnected) return;
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
const view = buildHopyoView(file.키, {
|
||||
scope,
|
||||
canWrite: opt.canWrite,
|
||||
onDeleted: () => {
|
||||
view.replaceChildren(el("p", { className: "m02-side__empty", text: "지운 실무자료" }));
|
||||
void refresh();
|
||||
},
|
||||
/** 파일 줄 누름 · 올린 직후 = 바로 엑셀 보기 창(본 칸은 그대로 · 열 때 불러옴) */
|
||||
async function showSheet(file: HopyoFile): Promise<void> {
|
||||
const sheet = await import("./M02_MasterTemplete_Hopyo_Sheet").catch((error: unknown) => {
|
||||
showToast(why(error), "error");
|
||||
return null;
|
||||
});
|
||||
opt.show(`${HOPYO_TITLE} — ${file.파일}`, view);
|
||||
opt.onOpen();
|
||||
open = { key: file.키, view };
|
||||
if (!sheet) return;
|
||||
sheetKey = file.키;
|
||||
paintList();
|
||||
sheet.openHopyoSheet({
|
||||
scope,
|
||||
key: file.키,
|
||||
파일: file.파일,
|
||||
canWrite: opt.canWrite,
|
||||
onClose: () => {
|
||||
if (sheetKey === file.키) sheetKey = null;
|
||||
paintList();
|
||||
},
|
||||
onDeleted: () => void refresh(),
|
||||
});
|
||||
}
|
||||
|
||||
async function showLedger(): Promise<void> {
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_View.ts
|
||||
* 일위대가 실무자료 본 화면(PLAN 324-4 · 338-3) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 제안 n/m · 호표 표 + 찾기 ·
|
||||
* [지우기] · [엑셀로 보기](329 엑셀 보기 창 · 늦게 불러옴) / 호표 하나 = 확인 창(`M02_MasterTemplete_Hopyo_Check` ·
|
||||
* 체크한 줄만 [확인하고 사전조합 만들기]) — 즉시 확정 · 바로 만들기 단추 없음.
|
||||
* 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import {
|
||||
deleteHopyo,
|
||||
readHopyo,
|
||||
type HopyoScope,
|
||||
type HopyoView,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { buildHopyoCheck, POLL_MS } from "./M02_MasterTemplete_Hopyo_Check";
|
||||
import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export interface HopyoViewOptions {
|
||||
scope: HopyoScope;
|
||||
/** 층 쓰기 권한 — 없으면 체크 · 만들기 · 지우기 단추 없음 */
|
||||
canWrite: boolean;
|
||||
/** 파일을 지웠음 — 좌측 목록 다시 */
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const won = (v: number | null | undefined): string =>
|
||||
v === null || v === undefined ? "" : v.toLocaleString("ko-KR");
|
||||
const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error");
|
||||
/** 엑셀 보기 창(329) — 열 때 불러옴 */
|
||||
const openSheet = (o: HopyoSheetOptions): void =>
|
||||
void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail);
|
||||
|
||||
const grid = (heads: string[], body: HTMLElement): HTMLElement =>
|
||||
el("div", {
|
||||
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-hopyo__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const td = (text: string, cls = ""): HTMLElement =>
|
||||
el("td", { text, className: cls, attrs: { title: text } });
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
|
||||
export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement {
|
||||
const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } });
|
||||
let nav = 0; // 늦게 온 응답 버림
|
||||
let query = "";
|
||||
|
||||
async function load<T>(get: () => Promise<T>, retry: () => void): Promise<T | null> {
|
||||
const my = ++nav;
|
||||
const stop = showLoading(view);
|
||||
try {
|
||||
const got = await get();
|
||||
return my === nav ? got : null;
|
||||
} catch (error) {
|
||||
if (my === nav) view.replaceChildren(loadingError(why(error), retry));
|
||||
return null;
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 파일 하나 — 호표 표 ─────────────────────────────────────── */
|
||||
async function drawFile(): Promise<void> {
|
||||
const file = await load(
|
||||
() => readHopyo(opt.scope, key),
|
||||
() => void drawFile(),
|
||||
);
|
||||
if (!file) return;
|
||||
let hopyos = file.호표;
|
||||
const search = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__search",
|
||||
attrs: { type: "search", placeholder: "호표 · 품명 · 규격 찾기", value: query },
|
||||
}) as HTMLInputElement;
|
||||
const body = el("tbody");
|
||||
const paint = (): void => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
query = search.value;
|
||||
body.replaceChildren(
|
||||
...hopyos
|
||||
.filter((h) => !q || `제${h.번}호표 ${h.이름} ${h.규격}`.toLowerCase().includes(q))
|
||||
.map((h) => {
|
||||
const tr = el("tr", {
|
||||
className: "m02-hopyo__row",
|
||||
attrs: { "data-hopyo-no": String(h.번) },
|
||||
children: [
|
||||
td(`제${h.번}호표`),
|
||||
td(h.이름),
|
||||
td(h.규격),
|
||||
td(h.단위),
|
||||
td(won(h.금액.계), "m02-hopyo__num"),
|
||||
td(`${h.맞춤[0]} / ${h.맞춤[1]}`, "m02-hopyo__num"),
|
||||
],
|
||||
});
|
||||
tr.addEventListener("click", () => void drawOne(h.번));
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
};
|
||||
search.addEventListener("input", paint);
|
||||
paint();
|
||||
// 뒤 숫자 판정 셈 중이면 3 초마다 다시 읽어 「제안 n / 줄 m」 갱신 — 셈 끝 · 다른 화면 · 못 읽음이면 멈춤
|
||||
const ready = note("");
|
||||
const calc = note("");
|
||||
const show = (f: HopyoView): void => {
|
||||
ready.textContent = `제안 ${f.맞춤[0]} / 줄 ${f.맞춤[1]}`;
|
||||
const c = f.셈 && !f.셈.끝 ? f.셈 : null;
|
||||
calc.textContent = c
|
||||
? `숫자 판정 셈 중 ${c.한}/${c.모두 ?? "?"}${c.까닭 ? ` · ${c.까닭}` : ""}`
|
||||
: "";
|
||||
calc.hidden = !c;
|
||||
};
|
||||
const my = nav;
|
||||
const live = (): boolean => my === nav && view.isConnected;
|
||||
const poll = (f: HopyoView): void => {
|
||||
if (!f.셈 || f.셈.끝) return;
|
||||
window.setTimeout(async () => {
|
||||
const next = live() ? await readHopyo(opt.scope, key).catch(() => null) : null;
|
||||
if (!next || !live()) return;
|
||||
hopyos = next.호표;
|
||||
show(next);
|
||||
paint();
|
||||
poll(next);
|
||||
}, POLL_MS);
|
||||
};
|
||||
show(file);
|
||||
poll(file);
|
||||
const wrong = file.검산.어긋남;
|
||||
view.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [
|
||||
el("strong", { text: file.파일 }),
|
||||
note(`시트 ${file.시트}`),
|
||||
note(`호표 ${file.호표수}`),
|
||||
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
|
||||
ready,
|
||||
calc,
|
||||
createButton({
|
||||
label: "엑셀로 보기",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openSheet({ scope: opt.scope, key, 파일: file.파일, canWrite: opt.canWrite }),
|
||||
}),
|
||||
...(opt.canWrite ? [dropButton(file)] : []),
|
||||
],
|
||||
}),
|
||||
...(wrong.length ? [el("p", { className: "m02-hopyo__warn", text: wrong.join("\n") })] : []),
|
||||
search,
|
||||
grid(["호표", "품명", "규격", "단위", "엑셀 단가", "제안 / 줄"], body),
|
||||
);
|
||||
}
|
||||
|
||||
const dropButton = (file: HopyoView): HTMLButtonElement =>
|
||||
createButton({
|
||||
label: "지우기",
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: async () => {
|
||||
const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 별칭 장부는 그대로`;
|
||||
if (!(await showConfirmDialog(ask, "지우기"))) return;
|
||||
try {
|
||||
await deleteHopyo(opt.scope, key);
|
||||
showToast("지움", "success");
|
||||
opt.onDeleted();
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/* ── 호표 하나 — 확인 창 ────────────────────────────────────── */
|
||||
function drawOne(no: number): void {
|
||||
nav += 1; // 파일 표 늦은 응답 버림
|
||||
view.replaceChildren(
|
||||
buildHopyoCheck({
|
||||
scope: opt.scope,
|
||||
key,
|
||||
no,
|
||||
canWrite: opt.canWrite,
|
||||
onBack: () => void drawFile(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void drawFile();
|
||||
return view;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ const TEXT = {
|
||||
Zoom_Out: ["작게", "Zoom out"],
|
||||
Fit: ["한눈에", "Fit"],
|
||||
Hint: [
|
||||
"가운데 단추 또는 Space + 끌기로 판 옮김 · Ctrl + 휠로 확대",
|
||||
"가운데 버튼 또는 Space + 끌기로 판 옮김 · Ctrl + 휠로 확대",
|
||||
"Middle button or Space + drag to move · Ctrl + wheel to zoom",
|
||||
],
|
||||
None: ["단가산출 로직이 없음", "No logics yet"],
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* M02 한 화면의 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 진입 해시
|
||||
* `#/m02-master-template?open=구조물조합|옵션|도면&root=master|company|personal&owner=…&company=…&pre=…`
|
||||
* (옛 `?layer=` 도 root 로 읽음 · `pre` = 일위대가 사전조합 층 · 134-3) · 고칠 권한(`accessFor` — 서버 `M02_Layer_Access` 와 짝 · 27-1c) ·
|
||||
* 회사 없음 안내 · 회사용 읽기만일 때 [개인용으로 복사] 막대.
|
||||
* 회사 없음 안내 · 회사용 읽기만일 때 [개인용 복사] 막대.
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
@@ -201,7 +201,7 @@ export interface ReadOnlyBar {
|
||||
setColumn: (column: string | null) => void;
|
||||
}
|
||||
|
||||
/** 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사](고른 열 하나를 내 개인 층으로) */
|
||||
/** 회사용을 읽기만 볼 때 — 안내 + [개인용 복사](고른 열 하나를 내 개인 층으로) */
|
||||
export function buildReadOnlyBar(user: SessionUser | null): ReadOnlyBar {
|
||||
let column: string | null = null;
|
||||
const copy = createButton({ label: L("M02_CopyToPersonal"), variant: "filled", size: "sm" });
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* M02_MasterTemplete_Practice.ts
|
||||
* 좌측 「실무문서」 컨테이너(구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만 · PLAN 39-2) —
|
||||
* 줄 하나를 누르면 메인 칸에 옛 마스터 일위대가 조합(`일위대가조합.json` 통째) 목록 · 상세(읽기만) ·
|
||||
* [통째 지우기](물음 두 번 → `DELETE /api/m02/practice` · 서버도 시스템 관리자 · 쓰는 곳 0 확인).
|
||||
* [전체 삭제](물음 두 번 → `DELETE /api/m02/practice` · 서버도 시스템 관리자 · 쓰는 곳 0 확인).
|
||||
* 읽기 = 마스터 조합 길(`Labor_Api`) · 복사 원본 고르기는 목록 화면(ItemList)의 실무문서 탭 몫.
|
||||
* ========================================================================== */
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ export function createMain(
|
||||
nav.update(back.hidden ? null : sel); // 보임 조건 = 뒤로 단추와 같음
|
||||
const kind = sel && sel.kind in HELP_TITLE ? (sel.kind as keyof typeof HELP_TITLE) : null;
|
||||
const help = kind && helpParts(HELP_TITLE[kind], detailHelp(kind)); // 열 때마다 새로 — 상자는 닫힘
|
||||
help?.button.classList.remove("ui-btn--sm"); // 머리 [?] = [다시 열기]와 같은 보통 크기(113-2 · 카드 [?]는 작게)
|
||||
help?.button.classList.remove("ui-btn--sm"); // 머리 [?] = [새로고침]와 같은 보통 크기(113-2 · 카드 [?]는 작게)
|
||||
helpSlot.replaceChildren(...(help ? [help.button, help.box] : []));
|
||||
};
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
}
|
||||
setActiveLayer(layer, owner);
|
||||
const isAdmin = accessFor(layer, owner, user).canWrite; // 이 층을 고칠 수 있나(하위 부품의 「관리자」)
|
||||
// 자기 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사]
|
||||
// 자기 회사용을 읽기만 볼 때 — 안내 + [개인용 복사]
|
||||
const readOnlyBar = layer === "company" && !owner && !isAdmin ? buildReadOnlyBar(user) : null;
|
||||
// 고른 층의 나무 — 고르기 모달과 같은 폴더 나무 · 좌측 「구조물 조합」 줄 아래
|
||||
// 「〇〇로 복사…」(30-2 · 39-6) — 쓸 수 있는 층 전부(마스터 · 볼 회사 · 내 개인 · 지금 보는 층은 뺌) ·
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Draft.ts
|
||||
* 확인 창 금액 미리 보기 꼴(PLAN 359-3) — 엑셀 줄 하나 + 지금 대상 → 일위대가 문서 한 벌(서버 `/labor/{열}/calc` 에
|
||||
* 보냄 · 저장 안 함 · 금액은 서버가 셈) · 줄이 들어갈 표(로직 표 · 자재 표).
|
||||
* 정본 = 서버 `M02_Hopyo_Build.Builder.build` 의 `put`([사전조합 생성] 때 다시 셈) — 같은 규칙:
|
||||
* 우리 수량 = 엑셀 수량 × 배 · 로직 = {로직, 입력값, 수량 {손}} · 자재 = {코드, 수량 {손}, 별도계상 =
|
||||
* 갈래 별도계상이면 「별도계상」 아니면 「직접계산」} · 같은 자재 = 수량 더함.
|
||||
* 미리 보기만 다른 것: 호표 대상(하위 사전조합 — 만든 뒤 셈) · 배 없는 대상은 뺌 ·
|
||||
* 같은 로직 둘째는 뺌(Build = 한 줄 사전조합으로 감쌈 · 한 문서에 같은 로직 둘 = 서버 400).
|
||||
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { HopyoLine, HopyoPart } from "../M02_MasterTemplete_Hopyo_Api";
|
||||
import type { LaborDoc, LaborMat, LaborRow } from "../M02_MasterTemplete_Labor_Api";
|
||||
|
||||
export type CheckTable = "logic" | "mat";
|
||||
|
||||
/** 엑셀 갈래 중 자재 표로 가는 것(대상 없을 때) */
|
||||
const MAT_KINDS = ["자재", "별도계상"];
|
||||
|
||||
/** 줄이 들어갈 표 — 대상이 모두 자재면 자재 표 · 대상 없으면 갈래(자재 · 별도계상) · 그 밖 로직 표 */
|
||||
export function tableOf(line: Pick<HopyoLine, "갈래">, parts: HopyoPart[]): CheckTable {
|
||||
if (parts.length) return parts.every((p) => !!p.자재) ? "mat" : "logic";
|
||||
return MAT_KINDS.includes(line.갈래) ? "mat" : "logic";
|
||||
}
|
||||
|
||||
/** 엑셀 줄 + 대상 → 미리 셈 문서 · 셀 것이 없으면 null(호표 대상뿐 · 배 없음 · 대상 없음) */
|
||||
export function draftDoc(
|
||||
line: Pick<HopyoLine, "수량" | "갈래">,
|
||||
parts: HopyoPart[],
|
||||
): LaborDoc | null {
|
||||
const rows: LaborRow[] = [];
|
||||
const mats = new Map<string, LaborMat>();
|
||||
const apart = line.갈래 === "별도계상" ? "별도계상" : "직접계산";
|
||||
for (const p of parts) {
|
||||
if (p.배 === null || p.호표 !== undefined) continue;
|
||||
const qty = (line.수량 ?? 0) * p.배;
|
||||
if (p.자재) {
|
||||
const have = mats.get(p.자재)?.수량;
|
||||
const hand = (have ? have.손 : 0) + qty;
|
||||
mats.set(p.자재, {
|
||||
코드: p.자재,
|
||||
이름: null,
|
||||
규격: null,
|
||||
단위: null,
|
||||
수량: { 손: hand },
|
||||
별도계상: apart,
|
||||
});
|
||||
} else if (p.로직 && !rows.some((r) => r.로직 === p.로직)) {
|
||||
const values = (p.입력값 ?? {}) as LaborRow["입력값"];
|
||||
rows.push({ 로직: p.로직, 별칭: null, 메모: null, 입력값: values, 수량: { 손: qty } });
|
||||
}
|
||||
}
|
||||
if (!rows.length && !mats.size) return null;
|
||||
return { 종류: "일위대가", 열: "", 단위: null, 담은로직: rows, 자재: [...mats.values()] };
|
||||
}
|
||||
@@ -4,6 +4,6 @@
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
## 버튼
|
||||
|
||||
## 주의할 점
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
## 버튼
|
||||
|
||||
## 주의할 점
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
## 버튼
|
||||
|
||||
## 주의할 점
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
## 버튼
|
||||
|
||||
## 주의할 점
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
## 버튼
|
||||
|
||||
## 주의할 점
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
## 버튼
|
||||
|
||||
## 주의할 점
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
## 버튼
|
||||
|
||||
## 주의할 점
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
## 버튼
|
||||
|
||||
## 주의할 점
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* 224-1b 유토 판 「붙어 있음」 판정 — 진짜 높이 나눔(`createHeightCascade`)과 방향키 판정
|
||||
/* 224-1b 유토 판 높이 나눔 · 방향키 판정 — 진짜 높이 나눔(`createHeightCascade`)과 방향키 판정
|
||||
* (`arrowsBelongToProfile` 본문)을 가짜 DOM 위에서 돌려 JSON 으로 냄.
|
||||
* · 열림 && 뿌리 안 = 자리 차지 · 열림 && 새 창(뿌리 밖) = 자리 0 · 인라인 높이 안 씀
|
||||
* · 유토곡선 = 종단 열 아래 자기 판(354-1) — 본문 높이에서 이미 빠져 나눔에 안 낌(입력에도 없음)
|
||||
* · 방향키 = 마지막 누름이 종단 뿌리 · 뗀 유토 · 뗀 배수 판 안이면 종단도 몫 */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
@@ -44,33 +44,27 @@ new Function(
|
||||
)(
|
||||
heightsExports,
|
||||
(id) =>
|
||||
id.includes("MassHaul")
|
||||
? { MASSHAUL_MIN_HEIGHT: 160 }
|
||||
: id.includes("TableOverlay")
|
||||
? { TABLE_OVERLAY_MIN_HEIGHT: 120 }
|
||||
: id.includes("Structures_Marks")
|
||||
? { STRUCTURE_LANE_HEIGHT_PX: 40 }
|
||||
: { frameOf: () => 0 },
|
||||
id.includes("TableOverlay")
|
||||
? { TABLE_OVERLAY_MIN_HEIGHT: 120 }
|
||||
: id.includes("Structures_Marks")
|
||||
? { STRUCTURE_LANE_HEIGHT_PX: 40 }
|
||||
: { frameOf: () => 0 },
|
||||
{ innerHeight: 900 },
|
||||
);
|
||||
|
||||
function run(docked) {
|
||||
function run(tableDesired) {
|
||||
const root = el("b05-route-profile");
|
||||
root.offsetHeight = 500;
|
||||
const body = root.append(el());
|
||||
body.clientHeight = 400;
|
||||
const overlay = el("b05-profile__masshaul-overlay");
|
||||
if (docked) body.append(overlay);
|
||||
else el("popup-body").append(overlay); // 새 창 body
|
||||
const overlay = el("b05-profile__table-overlay");
|
||||
const cascade = heightsExports.createHeightCascade({
|
||||
root,
|
||||
body,
|
||||
massHaul: { overlay, desiredHeight: () => 300, syncHandle() {} },
|
||||
tableOverlay: {
|
||||
overlay: el(),
|
||||
isOpen: () => false,
|
||||
desiredHeight: () => 0,
|
||||
setBottomOffset() {},
|
||||
overlay,
|
||||
isOpen: () => tableDesired > 0,
|
||||
desiredHeight: () => tableDesired,
|
||||
},
|
||||
isMainDragging: () => false,
|
||||
isSubDragging: () => false,
|
||||
@@ -99,8 +93,9 @@ const card = el("b06-cross-card");
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
docked: run(true),
|
||||
popped: run(false),
|
||||
tableClosed: run(0),
|
||||
tableFits: run(200),
|
||||
tableShrinks: run(300),
|
||||
arrows: {
|
||||
profile: arrows(profileChild),
|
||||
mass: arrows(massChild),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* 224-1c 유토곡선 · 배수유역 [창 분할] — 진짜 `attachSubPanelPopouts` · `attachProfilePopout`
|
||||
* (design_profile_popout.ts)를 가짜 DOM · 가짜 창 위에서 돌려 JSON 으로 냄.
|
||||
* · 단추 자리 · 글 · 클래스 · 툴팁 · 창 이름 셋 다름
|
||||
* · 뗌 = 새 창 body · 유토 손잡이 숨김 · 인라인 높이 비움 · 배수 rehome
|
||||
* · 붙임(단추 · 새 창 닫힘 · dispose) = 제자리(유토 손잡이 앞 · content 끝)
|
||||
* · 단추 = 두 판 머리줄 맨 앞(유토는 접어도 남는 머리줄 · 354-1)
|
||||
* · 뗌 = 새 창 body · 배수 rehome · 붙임(단추 · 새 창 닫힘 · dispose) = 제자리(종단 열 끝 · content 끝)
|
||||
* · 종단 새 창 안의 단추 = 그 창에서 엶 */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
@@ -122,21 +122,25 @@ new Function("exports", "require", "window", "document", js)(
|
||||
mainDoc,
|
||||
);
|
||||
|
||||
// ---- 종단 뿌리 = [content = [bodyWrap = [유토 판, 유토 손잡이], 배수 판 = [머리줄 = [제목]]]] ----
|
||||
// ---- 종단 뿌리 = [content = [열 = [종단 칸, 유토 판 = [머리줄 = [손잡이]]], 배수 판 = [머리줄 = [제목]]]] ----
|
||||
const slot = el("div");
|
||||
mainDoc.body.append(slot);
|
||||
const root = el("div", "b05-route-profile");
|
||||
slot.append(root);
|
||||
const content = el("div", "b05-route-profile__content");
|
||||
const bodyWrap = el("div");
|
||||
const column = el("div", "b05-route-profile__column");
|
||||
const bodyWrap = el("div", "b05-route-profile__body-wrap");
|
||||
const massOverlay = el("div", "b05-profile__masshaul-overlay");
|
||||
const massHead = el("div", "b05-profile__masshaul-head");
|
||||
const massHandle = el("div", "b05-profile__masshaul-handle");
|
||||
bodyWrap.append(massOverlay, massHandle);
|
||||
massHead.append(massHandle);
|
||||
massOverlay.append(el("div", "ui-resizer"), massHead);
|
||||
column.append(bodyWrap, massOverlay);
|
||||
const drainageRoot = el("aside", "b05-drainage");
|
||||
const header = el("div", "b05-drainage__header");
|
||||
header.append(el("h3"));
|
||||
drainageRoot.append(header);
|
||||
content.append(bodyWrap, drainageRoot);
|
||||
content.append(column, drainageRoot);
|
||||
root.append(content);
|
||||
let rehomes = 0;
|
||||
let disposed = 0;
|
||||
@@ -150,20 +154,18 @@ const profileButton = el("button");
|
||||
mod.attachProfilePopout(profile, slot, profileButton);
|
||||
mod.attachSubPanelPopouts(profile);
|
||||
|
||||
const massButton = massOverlay.children.find((c) => c.tagName === "BUTTON");
|
||||
const massButton = massHead.children[0];
|
||||
const drainButton = header.children[0];
|
||||
const btn = (b) => ({ text: b.textContent, title: b.title, className: b.className, type: b.type });
|
||||
const out = { mass: btn(massButton), drainage: btn(drainButton), profile: btn(profileButton) };
|
||||
|
||||
out.massButtonInHead = massButton.tagName === "BUTTON" && massHead.children[1] === massHandle;
|
||||
// 유토 뗌
|
||||
massOverlay.style.height = "123px";
|
||||
massButton.click();
|
||||
const massPopup = massOverlay.parent?.doc?.defaultView;
|
||||
out.massOpened = {
|
||||
inPopupBody: massOverlay.parent === massPopup?.document.body,
|
||||
isPopout: massOverlay.classList.contains("is-popout"),
|
||||
handleHidden: massHandle.hidden,
|
||||
inline: massOverlay.style.height,
|
||||
text: massButton.textContent,
|
||||
docTitle: massPopup?.document.title,
|
||||
};
|
||||
@@ -175,11 +177,10 @@ out.drainOpened = {
|
||||
rehomes,
|
||||
text: drainButton.textContent,
|
||||
};
|
||||
// 유토 새 창 닫힘 → 제자리(손잡이 바로 앞)
|
||||
// 유토 새 창 닫힘 → 제자리(종단 열 끝)
|
||||
massPopup.fire("pagehide");
|
||||
out.massClosed = {
|
||||
order: bodyWrap.children.map((c) => c.className),
|
||||
handleHidden: massHandle.hidden,
|
||||
order: column.children.map((c) => c.className),
|
||||
isPopout: massOverlay.classList.contains("is-popout"),
|
||||
text: massButton.textContent,
|
||||
};
|
||||
@@ -205,10 +206,8 @@ profile.dispose();
|
||||
out.disposed = {
|
||||
closed: [profilePopup.closed, massPopup2.closed, drainPopup2.closed],
|
||||
profileInSlot: root.parent === slot,
|
||||
massBeforeHandle:
|
||||
bodyWrap.children.indexOf(massOverlay) === bodyWrap.children.indexOf(massHandle) - 1,
|
||||
massInColumnEnd: column.children[column.children.length - 1] === massOverlay,
|
||||
drainInContent: drainageRoot.parent === content,
|
||||
handleHidden: massHandle.hidden,
|
||||
original: disposed,
|
||||
};
|
||||
out.opened = opened;
|
||||
|
||||
@@ -159,7 +159,10 @@ Promise.resolve(
|
||||
out.second = { count: snap.snapOldStations(again), toasts: [...toasts] };
|
||||
|
||||
// ④ 계획고 편집 따라감 — 한꺼번에 여럿 옮김(사라진 둘 · 새 둘)
|
||||
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts");
|
||||
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts");
|
||||
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
|
||||
"./B05_Profile_UI_Profile_Pivot": pivot,
|
||||
});
|
||||
const gone = [
|
||||
{ id: "pipe-80.17", chainage_m: 80.17 },
|
||||
{ id: "pipe-263.31", chainage_m: 263.31 },
|
||||
|
||||
@@ -65,6 +65,17 @@ const design = {
|
||||
{ offset_m: -7, elevation_m: 97.2 },
|
||||
],
|
||||
};
|
||||
// 356-1 양측절토 — 우(낮은 쪽) 노면 끝도 절토 → 노면을 늘여 원지반과 만난 점(tilt_reach) · 그 너머 원지반 오름
|
||||
const bothCut = {
|
||||
...design,
|
||||
design_line: [
|
||||
...design.design_line.slice(0, 3),
|
||||
{ offset_m: -4, elevation_m: 99.8 },
|
||||
{ offset_m: -4.5, elevation_m: 99.775 },
|
||||
{ offset_m: -6, elevation_m: 100.5 },
|
||||
],
|
||||
tilt_reach: { side: "right", offset_m: -4.5, elevation_m: 99.775 },
|
||||
};
|
||||
const svg = new Node("g");
|
||||
const group = draw.appendUnderdrainOverlay(
|
||||
svg,
|
||||
@@ -96,6 +107,7 @@ process.stdout.write(
|
||||
attached,
|
||||
sections: sections.map((s) => s.underdrain ?? null),
|
||||
outline: draw.underdrainOutline(design, 1),
|
||||
bothCut: draw.underdrainOutline(bothCut, 1),
|
||||
noHeight: draw.underdrainOutline(design, null),
|
||||
drew: !!group,
|
||||
bodyId: body?.attrs["data-structure-id"] ?? null,
|
||||
|
||||
@@ -20,7 +20,10 @@ function loadTs(file, stubs = {}) {
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts");
|
||||
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts");
|
||||
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
|
||||
"./B05_Profile_UI_Profile_Pivot": pivot,
|
||||
});
|
||||
const ground = loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", {
|
||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||
});
|
||||
|
||||
@@ -243,7 +243,7 @@ async function run() {
|
||||
// ③ 편집기 다리 — 사진첩 감싸기 · 초안 되살림
|
||||
{
|
||||
const told = [];
|
||||
let dirty = true;
|
||||
const dirty = true;
|
||||
const fake = {
|
||||
commit: () => undefined,
|
||||
undo: () => ({
|
||||
@@ -259,15 +259,6 @@ async function run() {
|
||||
picked: -1,
|
||||
}),
|
||||
redo: () => null,
|
||||
reset: () => ({
|
||||
planned: [[0, 0]],
|
||||
curveOn: [true],
|
||||
curveRadius: [null],
|
||||
curveLock: [null],
|
||||
curveArc: [null],
|
||||
apexLock: [false],
|
||||
picked: -1,
|
||||
}),
|
||||
canUndo: () => true,
|
||||
canRedo: () => false,
|
||||
isDirty: () => dirty,
|
||||
@@ -287,8 +278,6 @@ async function run() {
|
||||
});
|
||||
watched.undo();
|
||||
watched.redo();
|
||||
dirty = false;
|
||||
watched.reset();
|
||||
const restored = [];
|
||||
draftLink.restore(bridge, (snap, message, record) =>
|
||||
restored.push([snap.planned.length, record]),
|
||||
|
||||
@@ -151,11 +151,6 @@ const snap = (nodes, lock = false) => ({
|
||||
out.redo3 = state();
|
||||
out.editorAfterRedo = editor.slice(out.editorAfterUndo.length);
|
||||
out.profileReads = profileReads;
|
||||
// [반영 전으로] — 사진첩 한 걸음 · 되돌리면 옮긴 노선
|
||||
history.reset();
|
||||
out.reset = state();
|
||||
pageAlbum.undo();
|
||||
out.resetUndo = state();
|
||||
controls.dispose();
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})();
|
||||
|
||||
@@ -21,7 +21,10 @@ function loadTs(file, stubs = {}) {
|
||||
}
|
||||
|
||||
const curve = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts");
|
||||
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts");
|
||||
const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts");
|
||||
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
|
||||
"./B05_Profile_UI_Profile_Pivot": pivot,
|
||||
});
|
||||
const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", {
|
||||
"./B05_Profile_UI_Profile_Alignment": alignment,
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user