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

This commit is contained in:
2026-10-16 21:03:08 +09:00
88 changed files with 1554 additions and 806 deletions
+37
View File
@@ -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
View File
@@ -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",
+3 -3
View File
@@ -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));
}
+2 -8
View File
@@ -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;
+5 -6
View File
@@ -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,
+11 -7
View File
@@ -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 };
}
+8 -11
View File
@@ -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: "수식 숨김",
+1 -1
View File
@@ -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();
+4 -4
View File
@@ -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();
+16
View File
@@ -2,6 +2,7 @@
import html
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, Response
from pymysql.err import IntegrityError
@@ -378,9 +379,24 @@ async def dashboard_update_project(
if member_ids is not None:
# 만든 사람은 늘 참여자로 남는다.
await set_project_members(project_id, [int(project["user_id"]), *member_ids])
if _range_of(project) != _range_of(data):
# 사용 범위가 바뀌면 추천 시점 · 종점(예상노선)도 따라감 · 미리 셈(351-1)
from B03_FileInput.B03_FileInput_Router_Helpers import _schedule_background_task
from B05_Profile.B05_Profile_Router_RouteInitial import refresh_expected_route
_schedule_background_task(
refresh_expected_route(UUID(project_id)), task_name=f"예상노선 다시 씀 {project_id}"
)
return {"status": "success"}
def _range_of(row: dict[str, Any]) -> tuple[float | None, float | None]:
return tuple( # type: ignore[return-value]
None if row.get(key) is None else float(row[key])
for key in ("route_start_m", "route_end_m")
)
@router.delete("/projects/{project_id}")
async def dashboard_delete_project(
project_id: str,
@@ -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;
+3 -3
View File
@@ -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();
+6 -8
View File
@@ -342,15 +342,9 @@ async def run_auto_design_chain(
# 노선 추천 기본 다섯 알약 미리 셈(PLAN 327-2) — 예약만 · 체인이 [초기화] 요청 안에서도
# 불리므로 기다리지 않는다. 같은 입력이면 저장본이 있어 셈 없이 끝난다.
try:
from B03_FileInput.B03_FileInput_Router_Helpers import _schedule_background_task
from B05_Profile.B05_Profile_Router_RouteInitial import precompute_defaults
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
_schedule_background_task(
precompute_defaults(project_id), task_name=f"추천 노선 미리 셈 {project_id}"
)
except Exception: # noqa: BLE001 — 미리 셈이 체인을 막지는 않는다
logger.exception("추천 노선 미리 셈 예약 실패: project_id=%s", project_id)
schedule_precompute(project_id)
return {
"route_id": route_id,
@@ -663,6 +657,10 @@ async def run_redesign_chain(
return f"종횡단 확정 실패({sections_result.status_code})"
marks.append(("B06 확정(서버 재계산 포함)", time.perf_counter()))
_log_steps("재확정 체인", marks)
# 지형이 바뀌면 추천 키도 바뀜 — 첫 누름이 저장본이게 미리 셈(351-1)
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
schedule_precompute(project_id)
logger.info(
"재확정 체인 완료: project_id=%s route %s→%s (설계 %d건 이월)",
project_id,
@@ -10,6 +10,7 @@
키 = 후보 묶음 지문(`_pool_key` — 지형 · 예상노선 · 계류 · 상한 · 최소 R · 이격 · 고름 · 목표
기울기) + 갈래 · 비중 · 종단 반영 · 설계속도 · `ALGO_VERSION`. 관 지점은 뺀다 — 덮개 수는 꺼낼 때
다시 센다. 추천 결과를 바꾸는 코드 고침은 `ALGO_VERSION` 을 올린다(옛 파일은 다른 키라 안 쓰임).
기본값 · 저장된 제약 결과(`default`)는 「최근 벌」을 따로 센다(351-1).
"""
from __future__ import annotations
@@ -77,12 +78,26 @@ def request_fingerprint(
def is_default(
mode: str, weights: dict[str, float] | None, criteria: dict[str, Any] | None, terrain: Any
mode: str,
weights: dict[str, float] | None,
criteria: dict[str, Any] | None,
terrain: Any,
saved: dict[str, Any] | None = None,
) -> bool:
"""기본값 결과 — 빈 제약 · 50/25/25 · 설계 지형. 표시일 뿐 지우는 데 쓰지 않음."""
empty = not any(value is not None for value in (criteria or {}).values())
plain = mode != "weighted" or resolve_weights(mode, weights) == DEFAULT_WEIGHTS
return empty and plain and terrain is None
"""지킬 결과 — 기본값(빈 제약 · 50/25/25 · 설계 지형) 또는 저장된 제약(`initial_choice`) 그대로.
모달이 여는 값이라 「최근 2벌」 정리에서 따로 센다(351-1 · 사용자 시도에 안 밀림).
"""
if terrain is not None:
return False
filled = {key: value for key, value in (criteria or {}).items() if value is not None}
used = resolve_weights(mode, weights)
if not filled and (mode != "weighted" or used == DEFAULT_WEIGHTS):
return True
saved = saved or {}
return filled == (saved.get("criteria") or {}) and (
mode != "weighted" or used == saved.get("weights")
)
def read_stored(project_root: Path, key: str) -> dict[str, Any] | None:
@@ -125,23 +140,26 @@ def write_stored(
"result": result,
},
)
prune_stored(project_root, request)
prune_stored(project_root, request, default)
return True
def _kind(request: Any) -> tuple[Any, Any]:
def _kind(request: Any, default: Any) -> tuple[Any, Any, bool]:
request = request if isinstance(request, dict) else {}
return request.get("mode"), request.get("terrain")
return request.get("mode"), request.get("terrain"), bool(default)
def prune_stored(project_root: Path, request: dict[str, Any]) -> int:
"""같은 종류(갈래 · 지형) 저장본을 최근 `KEEP_PER_KIND` 벌만 남기고 지운다. 지운 수."""
kind = _kind(request)
def prune_stored(project_root: Path, request: dict[str, Any], default: bool = False) -> int:
"""같은 종류(갈래 · 지형 · 기본값 여부) 저장본을 최근 `KEEP_PER_KIND` 벌만 남기고 지운다.
기본값 · 저장된 제약 결과(`default`)는 따로 셈 — 사용자 시도가 밀어내지 않음(351-1). 지운 수.
"""
kind = _kind(request, default)
same: list[tuple[int, Path]] = []
for path in store_dir(project_root).glob("*.json"):
try:
data = json.loads(path.read_text(encoding="utf-8"))
if _kind(data.get("request")) == kind:
if _kind(data.get("request"), data.get("default")) == kind:
same.append((path.stat().st_mtime_ns, path))
except (OSError, ValueError, AttributeError):
continue
+88 -10
View File
@@ -76,6 +76,7 @@ async def compute_initial(
if paths is None:
return JSONResponse(status_code=404, content=replan._PROJECT_PATH_MISSING)
project_root, _ = paths
current = await asyncio.to_thread(read_initial_choice, project_root)
expected = await asyncio.to_thread(replan._vertices_of, expected_route_csv_path(project_root))
if not expected:
expected = await asyncio.to_thread(replan._vertices_of, design_route_csv_path(project_root))
@@ -144,6 +145,7 @@ async def compute_initial(
weights,
criteria.model_dump() if criteria else None,
request.terrain,
current,
),
)
profile = result["metrics"].get("profile")
@@ -162,7 +164,6 @@ async def compute_initial(
result["metrics"]["length_m"],
result["metrics"]["offset_avg_m"],
)
current = await asyncio.to_thread(read_initial_choice, project_root)
return {
"status": "success",
"project_id": str(project_id),
@@ -173,22 +174,99 @@ async def compute_initial(
async def precompute_defaults(project_id: UUID) -> None:
"""업로드 체인 끝 예약(PLAN 327-2) — 기본 다섯 알약을 미리 셈해 남김 · 있으면 꺼내기만."""
"""체인 끝 예약(PLAN 327-2 · 351-1) — 기본 다섯 알약 + 저장된 제약(initial_choice)의 다섯.
모달은 저장된 제약 · 비중으로 열리므로 그 값으로도 미리 셈한다. 있으면 꺼내기만.
"""
from B05_Profile.B05_Profile_Engine_RouteInitial import DEFAULT_WEIGHTS
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES
started = time.perf_counter()
for mode in DEFAULT_MODES:
# 화면 기본과 같은 몸통 — 빈 제약 · 50/25/25 · 설계 지형 · 추적만 종단 끔(`withProfileOf`)
request = replan.RouteInitialRequest(mode=mode, with_profile=mode != "follow")
outcome = await compute_initial(project_id, request)
if isinstance(outcome, JSONResponse):
logger.warning(
"추천 미리 셈 멈춤(%s %s): project_id=%s", mode, outcome.status_code, project_id
variants: list[tuple[Any, Any]] = [(None, None)]
try:
paths = await replan._project_paths(project_id)
except LookupError:
paths = None
if paths is not None:
saved = await asyncio.to_thread(read_initial_choice, paths[0])
weights = saved["weights"] if saved["logic"] == "weighted" else None
if saved["criteria"] or (weights and weights != DEFAULT_WEIGHTS):
variants.append((saved["criteria"], weights))
for criteria, weights in variants:
for mode in DEFAULT_MODES:
# 화면과 같은 몸통 — 설계 지형 · 비중은 비중 갈래만 · 추적만 종단 끔(`withProfileOf`)
request = replan.RouteInitialRequest(
mode=mode,
with_profile=mode != "follow",
criteria=criteria,
weights=weights if mode == "weighted" else None,
)
return
outcome = await compute_initial(project_id, request)
if isinstance(outcome, JSONResponse):
logger.warning(
"추천 미리 셈 멈춤(%s %s): project_id=%s", mode, outcome.status_code, project_id
)
return
logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started)
def schedule_precompute(project_id: UUID) -> None:
"""추천 미리 셈 예약 — 업로드 · 재확정 체인 끝(351-1). 기다리지 않음 · 실패는 로그만."""
try:
from B03_FileInput.B03_FileInput_Router_Helpers import _schedule_background_task
_schedule_background_task(
precompute_defaults(project_id), task_name=f"추천 노선 미리 셈 {project_id}"
)
except Exception: # noqa: BLE001 — 미리 셈이 체인을 막지는 않는다
logger.exception("추천 노선 미리 셈 예약 실패: project_id=%s", project_id)
async def refresh_expected_route(project_id: UUID) -> None:
"""B01 노선 사용 범위를 바꾼 뒤(351-1) — 예상노선을 새 범위로 다시 쓰고 추천 미리 셈.
체인을 아직 안 돈 프로젝트(예상노선 없음)는 건너뜀 — 체인이 새 범위로 적는다. 설계 노선
(수정본 · 초기 폴리라인)은 그대로 — 추천의 시점 · 종점만 따라간다.
"""
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_route_geometry import load_design_route, write_route_csv
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
pool = get_db_pool()
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
surface = await get_surface_confirmation_params(connection, str(project_id))
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT route_start_m, route_end_m FROM projects WHERE id = %s",
(str(project_id),),
)
row = await cursor.fetchone()
if not stored_path:
return
root = Path(resolve_stored_project_path(stored_path))
path = expected_route_csv_path(root)
if not path.is_file():
return
planned = await asyncio.to_thread(
functools.partial(
load_design_route,
root,
surface,
(row[0], row[1]) if row else None,
from_source=True,
)
)
if planned is None:
logger.warning("예상노선 다시 쓰기 건너뜀(노선 못 읽음): project_id=%s", project_id)
return
points = [{"x": v.x, "y": v.y} for v in planned.vertices]
await asyncio.to_thread(write_route_csv, path, points)
logger.info("예상노선 다시 씀(사용 범위): project_id=%s 점 %d", project_id, len(points))
await precompute_defaults(project_id)
def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int:
"""종단 정본의 1차 변화점(`base_pvi`) 계획고를 `stations` · `plan` 직선 보간으로 바꿔 쓴다."""
import json
+11 -20
View File
@@ -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;
+15 -44
View File
@@ -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,
),
};
}
+95 -90
View File
@@ -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,
};
}
+6 -3
View File
@@ -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;
/** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */
+57 -17
View File
@@ -43,26 +43,68 @@
}
/* 본문 칸을 세로로 쌓아 그래프 스크롤러 아래에 손잡이가 붙게 한다.
오버레이의 절대배치 기준이기도 하다. */
테이블 겹침 판의 절대배치 기준이기도 하다. */
.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);
z-index: 4;
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 +184,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 +204,7 @@
}
/* ── 테이블 오버레이 서브패널 (2026-08-05 사용자 지시) ──────────────────────
유토곡선과 같은 뼈대. 유토곡선이 열려 있으면 그 위에 쌓인다(bottom은 JS가 조절).
위→아래 순서: 종단도 → 테이블 → 유토곡선. */
종단 칸 바닥에 붙어 종단도 위를 덮는다. 유토곡선은 그 아래 자기 판(354-1). */
.b05-profile__table-overlay {
position: absolute;
right: 0;
@@ -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 [
+1 -4
View File
@@ -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 사용자 지적).
@@ -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 '';
+1 -1
View File
@@ -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 이동해 온 범위 [첫 줄, 끝 줄] — 고름과 한때 하나 */
+3 -3
View File
@@ -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"],
@@ -65,7 +65,7 @@ export const CARD_HELP: Record<"structure" | "labor" | "area", CardHelp> = {
["로직 줄 누름", "넣을 값 창 — 그 로직의 넣을 값 고침"],
["수량산출 칸", "수기 입력 · 면적산출 칸 고르기"],
["별칭 칸", "줄에 따로 이름 붙임"],
["줄 끝 단추", "그 줄을 뺌"],
["줄 끝 버튼", "그 줄을 뺌"],
["자재 추가", "자재를 골라 자재목록에 넣음"],
FOLD,
],
@@ -290,6 +290,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;
@@ -74,6 +74,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));
@@ -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,
@@ -64,6 +68,10 @@ export interface HopyoSheetOptions {
단산행?: number | [number, number];
/** 호표 하나를 만들었음 — [로직 추가] 참조 줄 */
onImport?: (got: HopyoBuilt, hopyo: HopyoBrief) => void;
/** 창을 닫았음(좌측 파일 줄 고름 표시) */
onClose?: () => void;
/** [삭제] 했음 — 좌측 목록 다시 · 없으면 [삭제] 단추 없음 */
onDeleted?: () => void;
}
const ILWI = "일위대가";
@@ -74,6 +82,9 @@ 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 });
/** 목록 숫자 「연결된 줄 / 연결할 줄」 — 다 연결(같고 0 넘음)이면 참 · 0/0 은 색 없음 */
export const allLinked = (m: [number, number]): boolean => m[1] > 0 && m[0] === m[1];
/** 엑셀 행(1부터)이 든 호표 번 · 없으면 null */
export const hopyoAtRow = (list: HopyoSpan[], row: number): number | null =>
list.find((h) => h.행[0] <= row && row <= h.행[1])?.번 ?? null;
@@ -119,7 +130,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 +145,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 +159,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 +241,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 +280,17 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
else checked.delete(h.번);
paintGo();
});
const linked = note(`${h.맞춤[0]}/${h.맞춤[1]}`);
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 +329,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 +351,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 +359,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 +388,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 +449,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 +459,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 +467,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"],
+6 -1
View File
@@ -265,6 +265,8 @@ def load_design_route(
project_root: Path,
surface_params: dict[str, Any] | None = None,
route_range: tuple[float | None, float | None] | None = None,
*,
from_source: bool = False,
) -> PlannedRoute | None:
"""설계가 쓸 계획노선 한 벌을 만든다 — 읽기·좌표계 변환·트림·조밀화를 여기서 끝낸다.
@@ -279,6 +281,9 @@ def load_design_route(
`route_range`(시작·종료 누가거리 m)를 주면 **서피스 트림보다 먼저** 그 구간만 남긴다
(2026-09-04 사용자 지시). 순서가 바뀌면 사용자가 정한 시점이 서피스 트림에 밀린다.
`from_source` 면 CSV 정본을 건너뛰고 올린 원본을 다시 읽는다 — 사용 범위를 바꾼 뒤
예상노선을 다시 쓸 때(351-1).
"""
from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj
from common_util.common_util_initial_snapshot import design_route_csv_path
@@ -293,7 +298,7 @@ def load_design_route(
# 지표면·노선이 바뀌면 `discard_initial_snapshot()`이 폴더째 지우므로 이 경로가 저절로
# 닫히고 원본 재판독으로 되돌아간다. 트림 **전** 원본이 필요한 호출(도엽 범위 —
# surface_params 없음)은 여기를 타지 않는다.
if surface_params:
if surface_params and not from_source:
# 읽는 순서 — 수정본 → **초기 폴리라인** → 예상노선(점 묶음) → 초기값 스냅샷.
# 초기 폴리라인이 예상노선보다 앞선다: 예상노선은 점 묶음이라 그대로 이으면
# 규칙 없는 선이 된다(2026-09-06 사용자 지시).
+1 -1
View File
@@ -4,6 +4,6 @@
## 작업 순서
## 단추
## 버튼
## 주의할 점
+1 -1
View File
@@ -4,6 +4,6 @@
## 작업 순서
## 단추
## 버튼
## 주의할 점
+1 -1
View File
@@ -4,6 +4,6 @@
## 작업 순서
## 단추
## 버튼
## 주의할 점
+1 -1
View File
@@ -4,6 +4,6 @@
## 작업 순서
## 단추
## 버튼
## 주의할 점
+1 -1
View File
@@ -4,6 +4,6 @@
## 작업 순서
## 단추
## 버튼
## 주의할 점
+1 -1
View File
@@ -4,6 +4,6 @@
## 작업 순서
## 단추
## 버튼
## 주의할 점
+1 -1
View File
@@ -4,6 +4,6 @@
## 작업 순서
## 단추
## 버튼
## 주의할 점
+1 -1
View File
@@ -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;
@@ -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,
@@ -0,0 +1,57 @@
/* 354-2 종단 기준 숨김 — 진짜 `readVisible`(B05 유토곡선) · `normalizeVisibleBasis` ·
* `computeMassHaulSeries`(공용)를 잘라 돌려 JSON 으로 냄.
* · 저장된 범례 = 종단 → 횡단으로 맞춤 · 분배 켬은 그대로
* · 기준 ["cross"] = 횡단 계열 하나 · 기본(묶음 단계) = 두 기준 그대로 */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), "utf8");
const transpile = (code) =>
ts.transpileModule(code, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const block = (src, head) => {
const at = src.indexOf(head);
if (at < 0) throw new Error(`없음: ${head}`);
return src.slice(at, src.indexOf("\n}", at) + 2).replace(/^export /, "");
};
const util = read("common_util", "common_util_mass_haul.ts");
const mass = read("B05_Profile", "B05_Profile_UI_Profile_MassHaul.ts");
let stored = null;
const src = [
'const SERIES_BASES = ["cross", "longitudinal"];',
'const DEFAULT_VISIBLE = ["cross"];',
block(util, "export function normalizeVisibleBasis("),
block(mass, "function readVisible(): Set<string> {"),
block(util, "export function computeMassHaulSeries("),
].join("\n");
const api = new Function(
"readStateRaw",
"designedSections",
"crossAreaSamples",
"longitudinalAreaSamples",
"integrate",
`${transpile(src)}; return { readVisible, computeMassHaulSeries };`,
)(
() => stored,
(sections) => sections,
() => [{ basis: "cross" }],
() => [{ basis: "longitudinal" }],
(samples) => ({ from: samples[0].basis }),
);
const visible = (raw) => ((stored = raw), [...api.readVisible()].sort());
process.stdout.write(
JSON.stringify({
longitudinal: visible('["longitudinal"]'),
longitudinalBalance: visible('["longitudinal","balance"]'),
cross: visible('["cross"]'),
empty: visible(null),
broken: visible("{"),
crossOnly: api.computeMassHaulSeries({}, [], {}, undefined, ["cross"]).map((s) => s.key),
both: api.computeMassHaulSeries({}, [], {}).map((s) => s.key),
}),
);
@@ -0,0 +1,121 @@
/* 354-3 스크롤 동기화 메아리 재현 — 진짜 휠 이동(`attachWheelHorizontalScroll`)과 진짜 잇기
* (`linkScrollLeft`)를 가짜 창 · 가짜 스크롤러 위에서 돌려 JSON 으로 냄.
* 가짜 창은 브라우저 차례 그대로 — scrollLeft 가 바뀌면 scroll 은 **다음 틀**에 오고(틀 앞머리),
* 그 뒤 화면 틀 콜백이 돎. 옛 잇기(동기 플래그)와 새 잇기를 같은 휠로 견줌.
* 경우 넷 = 표 켬/끔 × 분배(유토 전체 맞춤) 켬/끔 · 휠을 굴리는 쪽 = 종단 · 유토 둘. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const js = ts.transpileModule(
fs.readFileSync(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Wheel.ts"), "utf8"),
{ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } },
).outputText;
function fakeWindow() {
const win = { now: 0, frames: [], seq: 0, pending: new Set() };
win.requestAnimationFrame = (fn) => (win.frames.push({ id: ++win.seq, fn }), win.seq);
win.cancelAnimationFrame = (id) => (win.frames = win.frames.filter((f) => f.id !== id));
win.getSelection = () => ({ removeAllRanges() {} });
win.frame = () => {
win.now += 16;
const targets = [...win.pending];
win.pending.clear();
targets.forEach((el) => el.fire("scroll"));
const due = win.frames;
win.frames = [];
due.forEach((f) => f.fn(win.now));
};
return win;
}
function scroller(win, name, { hidden = false, scrollWidth = 6000 } = {}) {
const calls = {};
let left = 0;
const el = {
name,
ownerDocument: { defaultView: win },
clientWidth: hidden ? 0 : 1000,
scrollWidth: hidden ? 0 : scrollWidth,
scrollTop: 0,
style: {},
addEventListener: (type, fn) => (calls[type] ??= []).push(fn),
removeEventListener() {},
hasPointerCapture: () => false,
fire: (type, ev = {}) => {
const e = { preventDefault() {}, ...ev };
(calls[type] ?? []).forEach((fn) => fn(e));
},
};
Object.defineProperty(el, "scrollLeft", {
get: () => left,
set: (v) => {
const next = Math.min(Math.max(0, v), Math.max(0, el.scrollWidth - el.clientWidth));
if (next !== left) win.pending.add(el);
left = next;
},
});
return el;
}
/** 옛 잇기 — 유토곡선 · 표가 각자 들고 있던 동기 플래그 꼴 그대로. */
function oldLink(a, b, paused = () => false) {
let syncing = false;
const mirror = (from, to) =>
from.addEventListener("scroll", () => {
if (syncing || paused()) return;
syncing = true;
to.scrollLeft = from.scrollLeft;
syncing = false;
});
mirror(a, b);
mirror(b, a);
}
function run(kind, { table, balance, driver }) {
const win = fakeWindow();
const wheel = { exports: {} };
new Function("exports", "module", "require", "window", js)(
wheel.exports,
wheel,
() => ({ viewOf: () => win }),
win,
);
const body = scroller(win, "body");
// 분배 켬 = 유토곡선 전체 맞춤(스크롤 없음) · 잇기는 멈춤
const mass = scroller(win, "mass", { scrollWidth: balance ? 1000 : 6000 });
const tableEl = scroller(win, "table", { hidden: !table });
const link = kind === "old" ? oldLink : wheel.exports.linkScrollLeft;
link(body, mass, () => balance);
link(body, tableEl);
const lead = driver === "mass" && !balance ? mass : body;
[body, mass, tableEl].forEach((el) => wheel.exports.attachWheelHorizontalScroll(el));
const trace = [];
for (let i = 0; i < 60; i += 1) {
if (i < 20 && i % 2 === 0) lead.fire("wheel", { deltaY: 120 });
win.frame();
trace.push(body.scrollLeft);
}
let back = 0;
for (let i = 1; i < trace.length; i += 1) if (trace[i] < trace[i - 1] - 0.01) back += 1;
return {
back,
end: Math.round(body.scrollLeft),
lead: Math.round(lead.scrollLeft),
mass: Math.round(mass.scrollLeft),
table: Math.round(tableEl.scrollLeft),
};
}
const out = {};
for (const kind of ["old", "new"])
for (const driver of ["body", "mass"])
for (const table of [true, false])
for (const balance of [false, true])
out[`${kind}|${driver}|table=${table}|balance=${balance}`] = run(kind, {
table,
balance,
driver,
});
process.stdout.write(JSON.stringify(out));
@@ -185,6 +185,12 @@ let out0 = null;
{ 번: 7, 아래: [8] },
{ 번: 8, 아래: [7] },
]),
linked: [
[2, 2],
[1, 2],
[0, 0],
[3, 2],
].map(sheet.allLinked),
},
};
// 길 — 만들기(줄 · 통째) · 호표 하나(만들 층) · 미리 셈 · 장부
+17 -15
View File
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
"""224-1b(PLAN 325-2) 유토곡선 판 「붙어 있음」 판정 · 방향키.
"""224-1b(PLAN 325-2 · 354-1) 유토곡선 판 높이 나눔 · 방향키.
- 높이 나눔 · 끌기는 「열림 && 종단 뿌리 안」만 자리를 줌 — 새 창으로 뗀 유토 판은 메인 자리 0 ·
임시 축소 인라인 높이도 안 씀(뗀 창 높이가 굳지 않게).
- 유토곡선 = 종단 열 아래 자기 판(354-1) — 높이 나눔 · 끌기에 안 낌(본문 높이에서 이미 빠짐) ·
인라인 임시 축소도 안 씀. 나눔은 그래프 · 표 둘.
- 뗀 유토 판(`is-popout`)은 메인 보이는 구간 세로 범위(viewRange)를 안 씀 — 노선 전체 세로.
- 방향키 ←→ 판정은 마지막 누름이 종단 뿌리 · 뗀 유토 · 뗀 배수 판 안이면 종단도 몫.
@@ -34,13 +34,12 @@ def r():
return json.loads(proc.stdout)
def test_붙은_유토는_자리를_차지(r):
"""본문 400 − 알약 40 = 360 · 유토 300 은 예산 247 넘어 247 로 줄고 그래프 100."""
assert r["docked"] == {"chartHeight": 100, "inline": "247px"}
def test_뗀_유토는_자리_0_인라인_없음(r):
assert r["popped"] == {"chartHeight": 347, "inline": ""}
def test_유토_판은_나눔에_안_낌(r):
"""본문 400 − 알약 40 = 360 · 손잡이 띠 13 → 표 없음 그래프 347."""
assert r["tableClosed"] == {"chartHeight": 347, "inline": ""}
assert r["tableFits"] == {"chartHeight": 147, "inline": ""}
# 표 300 은 예산 247 넘어 247 로 줄고 그래프 100
assert r["tableShrinks"] == {"chartHeight": 100, "inline": "247px"}
def test_방향키_판정(r):
@@ -53,12 +52,15 @@ def test_방향키_판정(r):
}
def test_끌기도_같은_판정():
def test_끌기도_유토_판_안_셈():
drag = (B05 / "B05_Profile_UI_Profile_Drag.ts").read_text(encoding="utf-8")
assert "massHaul.overlay.hidden" in drag # 판정 함수 한 곳에만
assert drag.count("massHaul.overlay.hidden") == 1
assert drag.count("massDocked()") == 3
assert "root.contains(massHaul.overlay)" in drag
heights = (B05 / "B05_Profile_UI_Profile_Heights.ts").read_text(encoding="utf-8")
for src in (drag, heights):
assert "massDocked" not in src and "MASSHAUL_MIN_HEIGHT" not in src
assert "mass:" not in src
# 유토 판 리사이저 끌기 출처만 봄
assert drag.count("massHaul.overlay") == 1
assert "massHaul" not in heights
def test_뗀_유토는_노선_전체_세로():
@@ -1,10 +1,10 @@
# -*- coding: utf-8 -*-
"""224-1c(PLAN 325-3) 유토곡선 · 배수유역 [창 분할] — 종단 [창 분할]과 같은 양식 · 따로 새 창.
- 단추 = 유토 판 좌상단 · 배수 머리줄 맨 앞 · 글 [창 분할]/[창 결합] · 툴팁에 판 이름.
- 단추 = 두 판 머리줄 맨 앞(유토는 접어도 남는 머리줄 · 354-1) · 글 [창 분할]/[창 결합] · 툴팁에 판 이름.
- 창 이름 셋 다름(같으면 앞 창 body 를 덮어 조각이 사라짐).
- 뗌 = 새 창 body · 유토 손잡이 숨김 · 인라인 높이 비움 · 배수 지도 관찰 rehome.
- 붙임(단추 · 새 창 닫힘 · 페이지 떠남) = 제자리 — 유토 손잡이 바로 앞 · 배수 content 끝.
- 뗌 = 새 창 body · 뗀 유토 판 손잡이 · 리사이저는 CSS 로 감춤 · 배수 지도 관찰 rehome.
- 붙임(단추 · 새 창 닫힘 · 페이지 떠남) = 제자리 — 종단 열 끝(종단 칸 아래) · 배수 content 끝.
- 종단 새 창 안에서 다시 떼면 그 창이 새 창을 엶(메인 창 팝업 차단 피함).
`helper_224_1c_subpanel_popout.cjs` 가 진짜 TS 를 가짜 DOM · 가짜 창 위에서 돌림.
@@ -51,14 +51,13 @@ def test_단추_글_툴팁_클래스(r):
"type": "button",
}
assert "b05-route-profile__popout" in r["profile"]["className"]
assert r["massButtonInHead"] is True
def test_유토_뗌(r):
assert r["massOpened"] == {
"inPopupBody": True,
"isPopout": True,
"handleHidden": True,
"inline": "",
"text": "창 결합",
"docTitle": "유토곡선 — Aislo",
}
@@ -70,8 +69,7 @@ def test_배수_뗌은_rehome(r):
def test_새_창_닫힘과_단추로_제자리(r):
assert r["massClosed"] == {
"order": ["b05-profile__masshaul-overlay", "b05-profile__masshaul-handle"],
"handleHidden": False,
"order": ["b05-route-profile__body-wrap", "b05-profile__masshaul-overlay"],
"isPopout": False,
"text": "창 분할",
}
@@ -90,9 +88,8 @@ def test_페이지_떠나면_셋_다_닫고_제자리(r):
assert r["disposed"] == {
"closed": [True, True, True],
"profileInSlot": True,
"massBeforeHandle": True,
"massInColumnEnd": True,
"drainInContent": True,
"handleHidden": False,
"original": 1,
}
@@ -104,4 +101,5 @@ def test_배선과_양식():
block = re.search(r"\n\.b05-subpanel__popout \{([^}]*)\}", css).group(1)
assert "var(--btn-bar-padding)" in block and "var(--btn-bar-font-size)" in block
assert ".b05-profile__masshaul-overlay.is-popout," in css
assert ".b05-profile__masshaul-overlay.is-popout .b05-profile__masshaul-handle," in css
assert ".b05-drainage.is-popout {" in css
+2 -1
View File
@@ -67,7 +67,8 @@ def test_높이_맞춤(r):
def test_표_유토곡선_크기_끌기_다시_그림(r):
assert r["table"] == {"changed": 1, "handles": 2, "mainTimers": 0}
assert r["massHaul"] == {"changed": 1, "handles": 2, "mainTimers": 0}
# 유토 판 손잡이는 머리줄에 앉아 끌기 때 따라 옮길 것이 없음(354-1)
assert r["massHaul"] == {"changed": 1, "handles": 0, "mainTimers": 0}
def test_유토곡선_세로창_미끄러짐(r):
@@ -131,6 +131,22 @@ def test_형상_옵셋선_성토_절토와_만남(ts):
assert ts["drew"] is True and ts["stones"] > 0
def test_양측절토_늘인_노면_원지반_교점에서_끊음(ts):
"""356-1 — 낮은 쪽 노면 끝도 절토(tilt_reach) = reach 에서 수직으로 끊음 · 너머 원지반 따라가지 않음."""
outline = [(p["offset"], p["elevation"]) for p in ts["bothCut"]]
expected = [
(3, 100.15),
(-3, 99.85),
(-4, 99.8),
(-4.5, 99.775), # 늘인 노면 ∩ 원지반(tilt_reach)
(-4.5, 98.775), # reach 아래 옵셋선
(1.846154, 99.092308),
]
assert len(outline) == len(expected)
for got, want in zip(outline, expected):
assert got == pytest.approx(want, abs=1e-5)
def test_횡단도_B07_이_맹암거를_그림():
view = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8")
assert (
+2 -2
View File
@@ -249,7 +249,7 @@ def test_창_열_제목_강조_줄_자리():
)
assert out["boxes"] == ["bill"] and out["n"] == 1
assert out["cls"] == "b09-estimation b09-popup ui-popup" # 창 안 = b09 표 꼴(빨강 테두리)
assert out["head"] == ["설계내역서", "탭으로", "×"]
assert out["head"] == ["설계내역서", "탭 이동", "×"]
assert out["rows"] == ["b1", "b2", "b3"] # 그 줄 + 자손
assert out["rowCls"] == ["is-title is-pick is-pick-top", "is-pick", "is-pick is-pick-end"]
assert out["columns"] is True and out["caption"] == "설계내역서" # 열 제목(머리 여러 줄) 그대로
@@ -291,7 +291,7 @@ def test_닫기_탭으로_새_누름_주소():
"\nconst a = boxes();"
"\nopen(); tables[2].onCellClick(0, 'name'); open('b4');" # 화면 새 누름 = 있던 창 모두 닫고 새로
"\nconst b = [boxes(), rows(4)];"
"\nhead(0)[1].click();" # [탭으로] = 옮기고 모두 닫음
"\nhead(0)[1].click();" # [탭 이동] = 옮기고 모두 닫음
"\nconst c = [boxes(), [...gone]];"
"\nopen(); hashFns.forEach((f) => f());"
"\nconst d = boxes();"
+4 -1
View File
@@ -297,8 +297,11 @@ def test_추천_저장본_종류당_최근_두_벌(tmp_path: Path) -> None:
assert read_stored(tmp_path, "a") is not None # 꺼내면 최근으로
time.sleep(0.02)
write_stored(tmp_path, "c", "p", request, {"n": 3}, False)
time.sleep(0.02)
write_stored(tmp_path, "d", "p", request, {"n": 4}, False)
names = sorted(path.stem for path in store_dir(tmp_path).glob("*.json"))
assert names == ["a", "c", "g"]
# 기본값(a)은 따로 셈 — 사용자 시도 b · c · d 중 옛 b 만 지움(351-1)
assert names == ["a", "c", "d", "g"]
# ── 횡단 상세 — GET 은 안 지움 · ETag(R26) ───────────────────────────────────────
@@ -0,0 +1,194 @@
"""351-1 노선 추천 저장 빈틈 — 재확정 체인 · 기본값 지킴 · 저장된 제약 · B01 사용 범위.
./venv/Scripts/python.exe -m pytest resources/tester/test_351_1_recommend_gaps.py -q
체인 뒤 첫 누름 = 저장본(기본값 · 저장된 제약 둘 다) · 「최근 2벌」 정리가 기본값을 안 밀어냄 ·
사용 범위를 바꾸면 예상노선을 원본에서 다시 자르고 미리 셈.
"""
import asyncio
import json
import time
from pathlib import Path
from uuid import uuid4
import pytest
from B05_Profile import B05_Profile_Router_RouteInitial as router
from B05_Profile.B05_Profile_Engine_RouteInitial import write_initial_choice
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import (
DEFAULT_MODES,
is_default,
store_dir,
write_stored,
)
from common_util.common_util_route_geometry import (
expected_route_csv_path,
load_design_route,
planned_route_working_path,
write_route_csv,
)
from test_224_3_recommend_store import _ask, wired # noqa: F401 — 픽스처
def _saved(root: Path) -> list[dict]:
return [json.loads(p.read_text(encoding="utf-8")) for p in store_dir(root).glob("*.json")]
def test_저장된_제약으로도_미리_셈_첫_누름_저장본(wired) -> None: # noqa: F811
criteria = {"min_radius_m": 15.0, "max_grade_pct": 12.0}
write_initial_choice(
wired.root, "weighted", {"expected": 40, "earthwork": 30, "grade": 30}, criteria
)
asyncio.run(router.precompute_defaults(uuid4()))
assert wired.count == 2 * len(DEFAULT_MODES)
assert all(item["default"] for item in _saved(wired.root))
# 모달은 저장된 제약 · 비중으로 열림 — 셈 없이 저장본
hit = _ask(
mode="weighted", weights={"expected": 40, "earthwork": 30, "grade": 30}, criteria=criteria
)
gentle = _ask(mode="gentle", criteria=criteria)
plain = _ask(mode="earthwork")
assert hit["stored"] and gentle["stored"] and plain["stored"]
assert wired.count == 10
# 다시 불러도 셈 0
asyncio.run(router.precompute_defaults(uuid4()))
assert wired.count == 10
def test_저장된_제약_없으면_기본값만(wired) -> None: # noqa: F811
write_initial_choice(wired.root, "gentle", None, None)
asyncio.run(router.precompute_defaults(uuid4()))
assert wired.count == len(DEFAULT_MODES)
def test_기본값은_최근_두_벌_정리에서_안_밀림(tmp_path: Path) -> None:
request = {"mode": "earthwork", "terrain": None}
write_stored(tmp_path, "d", "p", request, {}, True)
for name in ("a", "b", "c"):
time.sleep(0.02)
write_stored(tmp_path, name, "p", request, {}, False)
names = sorted(path.stem for path in store_dir(tmp_path).glob("*.json"))
assert names == ["b", "c", "d"]
def test_기본값_판정() -> None:
saved = {
"criteria": {"min_radius_m": 15.0},
"weights": {"expected": 40.0, "earthwork": 30.0, "grade": 30.0},
}
assert is_default("gentle", None, None, None)
assert is_default("gentle", None, {"min_radius_m": None}, None)
assert not is_default("gentle", None, None, "las")
assert not is_default("gentle", None, {"min_radius_m": 15.0}, None)
assert is_default("gentle", None, {"min_radius_m": 15.0}, None, saved)
assert not is_default("gentle", None, {"min_radius_m": 16.0}, None, saved)
weights = {"expected": 40.0, "earthwork": 30.0, "grade": 30.0}
assert is_default("weighted", weights, {"min_radius_m": 15.0}, None, saved)
assert not is_default("weighted", None, {"min_radius_m": 15.0}, None, saved)
def test_두_체인_끝에서_미리_셈_예약() -> None:
source = Path("B03_FileInput/B03_FileInput_Service_Chain.py").read_text(encoding="utf-8")
redesign = source[source.index("async def run_redesign_chain(") :]
upload = source[: source.index("async def run_redesign_chain(")]
assert "schedule_precompute(project_id)" in upload
# 재확정 체인 성공 끝(완료 로그 앞)
done = redesign.index("재확정 체인 완료")
assert "schedule_precompute(project_id)" in redesign[:done]
b01 = Path("B01_Dashboard/B01_Dashboard_Router.py").read_text(encoding="utf-8")
assert "refresh_expected_route(" in b01
def test_사용_범위_비교() -> None:
from decimal import Decimal
from B01_Dashboard.B01_Dashboard_Router import _range_of
assert _range_of({"route_start_m": Decimal("10.0"), "route_end_m": None}) == (10.0, None)
assert _range_of({"route_start_m": 10.0, "route_end_m": None}) == _range_of(
{"route_start_m": Decimal("10"), "route_end_m": None}
)
assert _range_of({"route_start_m": 10.0}) != _range_of({"route_start_m": 20.0})
def _source_project(root: Path) -> None:
write_route_csv(
root / "B03_FileInput/input/route.csv",
[{"x": float(x), "y": 0.0} for x in range(0, 101, 10)],
)
# 설계 정본(수정본) · 예상노선이 따로 있음 — 원본에서 다시 읽어야 함
write_route_csv(planned_route_working_path(root), [{"x": 0.0, "y": 5.0}, {"x": 9.0, "y": 5.0}])
write_route_csv(expected_route_csv_path(root), [{"x": 0.0, "y": 0.0}, {"x": 100.0, "y": 0.0}])
def test_원본에서_다시_읽기(tmp_path: Path) -> None:
_source_project(tmp_path)
surface = {"source_filter": "csf", "method": "dtm", "smooth": False}
stored = load_design_route(tmp_path, surface, (20.0, 80.0))
assert [v.y for v in stored.vertices] == [5.0, 5.0] # 정본이 먼저
fresh = load_design_route(tmp_path, surface, (20.0, 80.0), from_source=True)
xs = [round(v.x, 3) for v in fresh.vertices]
assert xs[0] == 20.0 and xs[-1] == 80.0
def test_사용_범위_바꾸면_예상노선_다시_쓰고_미리_셈(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import B03_FileInput.B03_FileInput_Repository as repo
import common_util.common_util_storage as storage
import common_util.common_util_surface_confirmation as surface
_source_project(tmp_path)
calls: list[str] = []
class _Cursor:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return None
async def execute(self, *_):
return None
async def fetchone(self):
return (30.0, 70.0)
class _Connection:
def cursor(self):
return _Cursor()
class _Acquire:
async def __aenter__(self):
return _Connection()
async def __aexit__(self, *_):
return None
class _Pool:
def acquire(self):
return _Acquire()
async def storage_path(*_):
return "x"
async def surface_params(*_):
return {"source_filter": "csf", "method": "dtm", "smooth": False}
async def precompute(_pid):
calls.append("precompute")
monkeypatch.setattr(router, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(repo, "get_project_storage_relative_path", storage_path)
monkeypatch.setattr(storage, "resolve_stored_project_path", lambda _p: str(tmp_path))
monkeypatch.setattr(surface, "get_surface_confirmation_params", surface_params)
monkeypatch.setattr(router, "precompute_defaults", precompute)
asyncio.run(router.refresh_expected_route(uuid4()))
rows = expected_route_csv_path(tmp_path).read_text(encoding="utf-8").splitlines()
assert rows[1].split(",")[1:] == ["30.0", "0.0"] and rows[-1].split(",")[1:] == ["70.0", "0.0"]
assert calls == ["precompute"]
# 체인 전(예상노선 없음)은 건너뜀
expected_route_csv_path(tmp_path).unlink()
asyncio.run(router.refresh_expected_route(uuid4()))
assert calls == ["precompute"] and not expected_route_csv_path(tmp_path).exists()
@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
"""354-1 유토곡선 = 배수유역도처럼 자기 판(요구 235-7).
- 종단 열(`b05-route-profile__column`) = [종단 칸(bodyWrap), 유토 판] · content 안이라 종단 [창 분할]
(`.is-popout > :not(content)` 숨김)에도 같이 감 · B05 · B06 이 같은 `design_profile` 하나.
- 유토 판은 흐름 칸(겹침 아님) · 자기 리사이저 높이 · 접으면 머리줄만(손잡이 · [창 분할] 닿음).
- 높이 나눔 · 표 쌓기에서 빠짐(표 bottomOffset 없음) · X 동기화는 그대로.
"""
import os
import re
from pathlib import Path
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = Path(HERE).parents[1]
def read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def css_block(css: str, selector: str) -> str:
m = re.search(r"(?:^|\n)" + re.escape(selector) + r" \{([^}]*)\}", css)
assert m, selector
return m.group(1)
def test_종단_열_아래_자기_칸():
overlays = read("A00_Common/design/design_profile_overlays.ts")
assert 'column.className = "b05-route-profile__column";' in overlays
assert "column.append(bodyWrap, massHaul.overlay);" in overlays
assert "bodyWrap.append(massHaul.overlay" not in overlays
assert "massHaul.attachScrollSync(body);" in overlays
profile = read("A00_Common/design/design_profile.ts")
assert "content.append(column);" in profile
assert sum(1 for _ in profile.splitlines()) <= 700
def test_종단_창_분할에도_안_숨음():
page = read("A00_Common/design/design_page.css")
assert ".b05-route-profile.is-popout > :not(.b05-route-profile__content)" in page
# 유토 판은 뿌리 바로 아래가 아니라 content > column 안
overlays = read("A00_Common/design/design_profile_overlays.ts")
assert "root.append" not in overlays
def test_흐름_칸_자기_높이_접으면_머리줄():
css = read("B05_Profile/B05_Profile_UI_Style_MassHaul.css")
panel = css_block(css, ".b05-profile__masshaul-overlay")
assert "position: relative;" in panel and "absolute" not in panel
assert "height: var(--b05-masshaul-height, 280px);" in panel
assert "flex: none;" in panel
column = css_block(css, ".b05-route-profile__column")
assert "flex-direction: column;" in column
hide = css_block(
css,
".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > :not(.b05-profile__masshaul-head)",
)
assert "display: none;" in hide
assert "display: none;" in css_block(
css, ".b05-route-profile.is-collapsed .b05-profile__masshaul-overlay"
)
mass = read("B05_Profile/B05_Profile_UI_Profile_MassHaul.ts")
assert "head.append(handle);" in mass
assert "overlay.append(resizer.root, head, bar, scroll, legendLayer, pendingChip);" in mass
assert 'overlay.classList.toggle("is-collapsed", !next);' in mass
assert "overlay.hidden" not in mass
# 새 창으로 뗀 판은 접힘이어도 그림
assert 'const shown = (): boolean => open || overlay.classList.contains("is-popout");' in mass
def test_창_분할_단추는_머리줄_맨_앞():
popout = read("A00_Common/design/design_profile_popout.ts")
assert "massHead.prepend(button);" in popout
assert "home: () => column?.append(massOverlay)," in popout
page = read("A00_Common/design/design_page.css")
assert ".b05-profile__masshaul-overlay > .b05-subpanel__popout" not in page
def test_높이_나눔_표_쌓기에서_빠짐():
heights = read("B05_Profile/B05_Profile_UI_Profile_Heights.ts")
table = read("B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts")
assert "massHaul" not in heights
assert "setBottomOffset" not in heights and "setBottomOffset" not in table
assert "bottomOffset" not in table
@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
"""354-2 유토곡선 종단 기준 숨김 — 안 셈 · 안 그림 · 범례 단추 없음(코드 남김).
- 그리기 = 횡단 기준만(`["cross"]`) · 범례는 낸 계열마다라 종단 단추가 안 섬.
- 저장된 범례가 종단(`longitudinal`)이면 횡단으로 맞춤 · 분배 켬은 그대로.
- 계산 묶음 단계(MassHaul_Stage)는 두 기준 그대로 · 공용 계산 코드 남김.
`helper_354_2_masshaul_cross_only.cjs` 가 진짜 TS 를 잘라 돌림.
"""
import json
import os
import re
import subprocess
from pathlib import Path
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = Path(HERE).parents[1]
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_354_2_masshaul_cross_only.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_저장된_종단_범례는_횡단으로(r):
assert r["longitudinal"] == ["cross"]
assert r["longitudinalBalance"] == ["balance", "cross"]
assert r["cross"] == ["cross"]
assert r["empty"] == ["cross"] and r["broken"] == ["cross"]
def test_기준_횡단만_계산(r):
assert r["crossOnly"] == ["cross"]
assert r["both"] == ["cross", "longitudinal"] # 공용 기본값 = 묶음 단계 그대로
def test_그리기는_횡단만_부름():
mass = (ROOT / "B05_Profile/B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8")
calls = re.findall(r"computeMassHaulSeries\(([^;]*?)\)", mass, re.S)
assert calls and all('["cross"]' in call for call in calls)
def test_묶음_단계와_공용_코드는_그대로():
stage = (ROOT / "B05_Profile/logic/B05_Profile_MassHaul_Stage.ts").read_text(encoding="utf-8")
assert '["cross"]' not in stage
util = (ROOT / "common_util/common_util_mass_haul.ts").read_text(encoding="utf-8")
assert 'const SERIES_BASES: MassHaulBasis[] = ["cross", "longitudinal"];' in util
assert "longitudinalAreaSamples(longitudinal, sections)" in util
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""354-3 종단 그래프 스크롤 끊김 — 동기화 메아리 · 스크롤마다 유토 다시 셈.
- 메아리: scroll 은 다음 틀에 옴 → 동기 플래그로 못 막아 한 틀 전 값을 되씀 → 뒤로 튐 →
휠 이동이 접힘. 공용 `linkScrollLeft`(Wheel.ts)가 써 넣은 값으로 온 scroll 을 무시.
가짜 창(다음 틀 scroll) 재현 — 옛 잇기는 뒤로 튐 · 멈춤, 새 잇기는 4 경우 × 휠 쪽 둘 모두 튐 0.
- 유토 스크롤 그리기 = 열림 · 분배 맞춤 아님 · 붙음일 때만 · 한 틀 한 번 · 다시 셈 없음.
`helper_354_3_scroll_echo.cjs` 가 진짜 휠 · 잇기 TS 를 가짜 창 위에서 돌림.
"""
import json
import os
import re
import subprocess
from pathlib import Path
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
B05 = Path(HERE).parents[1] / "B05_Profile"
CASES = [
f"{driver}|table={table}|balance={balance}"
for driver in ("body", "mass")
for table in ("true", "false")
for balance in ("false", "true")
]
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_354_3_scroll_echo.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_옛_잇기는_메아리로_뒤로_튐(r):
# 표 · 분배 끔(사용자 보고 경우)에서 휠 다섯 칸(1,200px) 중 210px 에서 멈춤
assert r["old|body|table=false|balance=false"]["back"] > 0
assert r["old|body|table=false|balance=false"]["end"] < 1200
# 분배 켜고 표 끔 = 잇기 없음 — 사용자 관찰(분배 켜면 매끄러움)과 같음
assert r["old|body|table=false|balance=true"] == {
"back": 0,
"end": 1200,
"lead": 1200,
"mass": 0,
"table": 0,
}
@pytest.mark.parametrize("case", CASES)
def test_새_잇기는_뒤로_튐_0_끝까지(r, case):
got = r[f"new|{case}"]
assert got["back"] == 0
assert got["end"] == 1200 and got["lead"] == 1200
table, balance = "table=true" in case, "balance=true" in case
assert got["table"] == (1200 if table else 0)
assert got["mass"] == (0 if balance else 1200)
def test_유토_표_같은_도우미():
for name in ("MassHaul", "TableOverlay"):
src = (B05 / f"B05_Profile_UI_Profile_{name}.ts").read_text(encoding="utf-8")
assert "linkScrollLeft(main, scroll" in src
assert "syncing" not in src
def test_유토_스크롤_그리기는_창_따를_때만_한_틀_한_번():
mass = (B05 / "B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8")
body = mass[mass.index("function drawWindow(") :]
body = body[: body.index("\n }\n")]
assert '!open || fitted || overlay.classList.contains("is-popout") || !lastSeries' in body
assert "windowState.frame" in body and "frameOf(overlay" in body
assert "draw(lastParams, true)" in body
# 다시 셈 없음 · 총괄값 · 범례는 계열이 바뀔 때만
assert re.search(r"reuse && lastSeries\s*\? lastSeries", mass)
assert "if (series !== lastSeries) emitSummary(series);" in mass
# 다시 그려도 제 자리 — 종단 값으로 되쓰는 것은 처음 · 맞춤 뒤 · 창 옮김 뒤만
assert "scroll.scrollLeft = syncedMain.scrollLeft" not in mass
render = (B05 / "B05_Profile_UI_Profile_Render.ts").read_text(encoding="utf-8")
assert "massHaul.drawWindow({" in render
@@ -0,0 +1,19 @@
"""356 B06 횡단 카드 — 키 강조 · 중심점 좌우(요구 235-17 · 18).
못박는 것
- 356-2 카드 초점 없음 — 마우스로 고른 뒤 키를 눌러도 초점 테두리(다른 색) 안 뜸 · ▲▼ 계획고는 창 듣개.
"""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
B06 = ROOT / "B06_Section"
def test_카드_초점_없음():
view = (B06 / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8")
assert "card.tabIndex" not in view
assert 'card.addEventListener("keydown"' not in view
assert 'card.addEventListener("click", () => onSelect(section.station_id));' in view
+11 -10
View File
@@ -140,16 +140,16 @@ def test_설정_창_탭_저장_물음():
page.click(".ui-confirm button >> nth=0") # 취소
assert page.locator(".app-settings").count() == 1 and _writes(page) == []
# [저장] = 한 번 씀 · 읽기 층 방향 뒤집힘 · 닫을 때 안 물음
# [저장] = 한 번 씀 · 바로 닫힘(물음 없음) · 읽기 층 방향 뒤집힘
_save(page)
assert page.locator(".app-settings").count() == 0
assert page.locator(".ui-confirm").count() == 0
assert len(_writes(page)) == 1
assert json.loads(_writes(page)[0]) == {"wheelZoom": "pull", "cadAliases": {}}
assert page.evaluate(WHEEL_IN, 100) is True and page.evaluate(WHEEL_IN, -100) is False
_save(page) # 고친 것 없음 — 또 안 씀
assert len(_writes(page)) == 1
_close(page)
assert page.locator(".app-settings").count() == 0
assert page.locator(".ui-confirm").count() == 0
_open_settings(page)
_save(page) # 고친 것 없음 — 또 안 씀 · 닫힘
assert len(_writes(page)) == 1 and page.locator(".app-settings").count() == 0
# CAD 별칭 — 잘못이면 안 씀 · 칸 표시
_open_settings(page)
@@ -163,25 +163,26 @@ def test_설정_창_탭_저장_물음():
_save(page)
assert page.inner_text(".app-settings__error").strip(), (line_v, circle_v)
assert page.locator(".app-settings input[aria-invalid=true]").count() >= 1
assert len(_writes(page)) == 1
assert len(_writes(page)) == 1 and page.locator(".app-settings").count() == 1 # 창 남음
page.fill(line, "ll")
page.fill(circle, "")
_save(page)
assert page.inner_text(".app-settings__error").strip() == ""
assert page.locator(".app-settings").count() == 0 # 저장 성공 = 닫힘
assert len(_writes(page)) == 2
assert json.loads(_writes(page)[1]) == {"wheelZoom": "pull", "cadAliases": {"LINE": ["LL"]}}
# 닫을 때 물음 — 버림 = 안 씀 · 저장 = 한 번 더
_open_settings(page)
page.click("[data-settings-tab=mouse]")
page.check("input[name=app-settings-wheel][value=push]")
_close(page)
page.click(".ui-confirm button >> nth=1") # 버리고 넘어감
page.click(".ui-confirm button >> nth=1") # 폐기
assert page.locator(".app-settings").count() == 0 and len(_writes(page)) == 2
assert page.evaluate(WHEEL_IN, 100) is True
_open_settings(page)
page.check("input[name=app-settings-wheel][value=push]")
_close(page)
page.click(".ui-confirm button >> nth=2") # 저장 후 넘어감
page.click(".ui-confirm button >> nth=2") # 저장
assert page.locator(".app-settings").count() == 0 and len(_writes(page)) == 3
assert json.loads(_writes(page)[2])["wheelZoom"] == "push"
assert page.evaluate(WHEEL_IN, 100) is False
+40 -21
View File
@@ -24,7 +24,6 @@ NEW = (
"Hopyo_Api",
"Hopyo_Pick",
"Hopyo_Side",
"Hopyo_View",
"Hopyo_Sheet",
"Hopyo_Check",
"Hopyo_Choose",
@@ -192,7 +191,9 @@ def test_로직_추가_확인된_것은_바로_참조_아니면_확인_창():
def test_엑셀_보기_창_하위_호표_먼저():
assert _check()["sheet"]["order"] == [46, 45, 3, 7, 8] # 순환은 남은 차례
got = _check()["sheet"]
assert got["order"] == [46, 45, 3, 7, 8] # 순환은 남은 차례
assert got["linked"] == [True, False, False, False] # 다 연결만 녹색 · 0/0 색 없음(359-2)
def _pick() -> dict:
@@ -279,18 +280,27 @@ def test_옛_즉시_확정과_바로_만들기_없음():
):
assert label not in every, label
users = sorted(name for name, text in texts.items() if "buildHopyo(" in text)
assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 펼침 뒤 한 번
assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 엑셀 보기 [저장] 물음 뒤
check = texts["Hopyo_Check"]
assert '"확인하고 사전조합 만들기"' in check and "맞음 · 별칭 모두 체크" in check
assert "showLoadingOverlay()" in check and "dropDraft(k)" in check
assert "m02-hopyo-check:" in check and "POLL_MS" in check
sheet = texts["Hopyo_Sheet"]
assert "확인하고 만들기" in sheet and "이대로 ${ready.length}개 만들기" in sheet
assert "확인 창 열기" in sheet and "buildOrder(" in sheet
# 359-2 — 펼침 표 · [확인 창 열기] · [이대로 n개 만들기] 없음 · [저장] = 물음 한 번 뒤 체크 줄로 만들기
for gone in ("확인 창 열기", "이대로", "review(", "확인하고 만들기"):
assert gone not in sheet, gone
save = sheet.split("async function save(")[1].split("async function makeAll(")[0]
assert 'showConfirmDialog(ask, "저장")' in save and "hasDraft(" in save
assert "건너뜀 — 체크 줄 없음" in save and "await makeAll(ready, skipped);" in save
assert "buildOrder(" in sheet and "error instanceof StaleError" in sheet # 하위 먼저 · 409 안내
edit = sheet.split("const editButton =")[1].split("function paintList(")[0]
assert '"수정"' in edit and "e.stopPropagation();" in edit and "openHopyoCheck({" in edit
row = sheet.split("function paintList(")[1]
assert row.index("m02-hopyo-sheet__no") < row.index("editButton(h)") < row.index("__name")
assert 'linked.classList.toggle("is-linked", allLinked(h.맞춤));' in row
pick = texts["Hopyo_Pick"]
assert "if (row.확인)" in pick and 'import("./M02_MasterTemplete_Hopyo_Check")' in pick
view = texts["Hopyo_View"]
assert "buildHopyoCheck(" in view and "choose(" not in view
assert not (M02 / "M02_MasterTemplete_Hopyo_View.ts").exists() # 본 칸 표 없음(359-1)
ledger = texts["Hopyo_Ledger"]
assert "m02-hopyo-ledger:" in ledger and "saveLedger(" in ledger
for gone in ("발주처", "회사로 올리기", "쓰인 횟수"):
@@ -306,6 +316,13 @@ def test_좌측_판_자리와_권한():
assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
# 359-1 — [파일 업로드] = 구조물 조합 층 단추 꼴(보통 크기 · m02-layers__btn) · 파일 줄 = 바로 엑셀 보기
up = hopyo_side.split("const upload = createButton({")[1].split("upload.title")[0]
assert 'label: "파일 업로드"' in up and 'upload.classList.add("m02-layers__btn");' in up
assert "size:" not in up and "엑셀 올리기" not in hopyo_side
assert "buildHopyoView" not in hopyo_side
assert 'row.addEventListener("click", () => void showSheet(f));' in hopyo_side
assert "onClose:" in hopyo_side and "onDeleted: () => void refresh()," in hopyo_side
assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side
assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side
assert "buildHopyoLedger({ scope, canWrite: opt.canWrite })" in hopyo_side # [별칭 장부]
@@ -332,16 +349,15 @@ def test_로직_추가_한_곳과_파일_규칙():
def test_엑셀_보기_창_여는_자리와_늦게_불러옴():
lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")'
side = _text("M02_MasterTemplete_Hopyo_Side.ts")
assert lazy in side.split("await show(got);")[1].split("})")[0] # 올린 직후 자동
view = _text("M02_MasterTemplete_Hopyo_View.ts")
assert lazy in view and '"엑셀로 보기"' in view
assert "await showSheet(got);" in side # 올린 직후 자동
assert lazy in side.split("async function showSheet(")[1].split("async function showLedger(")[0]
check = _text("M02_MasterTemplete_Hopyo_Check.ts")
assert lazy in check and '"엑셀에서 보기"' in check and "호표: opt.no" in check
assert "단산행: 행" in check
pick = _text("M02_MasterTemplete_Hopyo_Pick.ts")
assert lazy in pick
static = r'^import (?!type )[^(;]*"\./M02_MasterTemplete_Hopyo_Sheet"'
for text in (side, view, pick, check):
for text in (side, pick, check):
assert not re.search(static, text, re.M) # 정적 import 없음(타입만)
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
assert 'import("../A00_Common/spreadsheet/spreadsheet")' in sheet
@@ -400,15 +416,18 @@ def test_확인_창_손질_339():
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
assert "poll(file);" in sheet and "poll(next);" in sheet and "POLL_MS" in sheet
assert "if (ok) document.dispatchEvent(new Event(HOPYO_BUILT));" in sheet
assert '`맞음 ${tally("맞음")} · 다른 점 ${tally("다른 점")}`' in sheet
def test_본_칸_호표_목록_셈_끝까지_다시_읽음_339():
"""339 덧고침 2 — 본 칸 호표 표 「제안 n / 줄 m」 · 머리 「셈 중」 — 셈 중이면 3 초마다 다시 읽음."""
view = _text("M02_MasterTemplete_Hopyo_View.ts")
assert "poll(file);" in view and "poll(next);" in view and "POLL_MS" in view
assert "hopyos = next.호표;" in view and "...hopyos" in view
assert (
"const live = (): boolean => my === nav && view.isConnected;" in view
) # 호표 하나로 가면 멈춤
assert "숫자 판정 셈 중" in view
def test_엑셀_보기_머리_막대_셈_끝까지_다시_읽음_359():
"""339 덧고침 2 → 359-1 — 옛 본 칸 표 몫(시트 · 호표 수 · 검산 · 「제안 n / 줄 m」 · 「셈 중」 · [삭제])은
엑셀 보기 창 머리 · 셈 중이면 3 초마다 다시 읽음(머리 · 목록 함께)."""
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
head = sheet.split("function paintHead(")[1].split("const dropButton")[0]
for word in ("시트 ", "호표 ", "검산 어긋남", "검산 맞음", "제안 ", "숫자 판정 셈 중"):
assert word in head, word
assert "opt.canWrite && opt.onDeleted ? [dropButton()]" in head
drop = sheet.split("const dropButton =")[1].split("/** 목록 강조")[0]
assert 'label: "삭제"' in drop and 'showConfirmDialog(ask, "삭제")' in drop
assert drop.index("deleteHopyo(") < drop.index("modal.close();") < drop.index("opt.onDeleted")
poll = sheet.split("function poll(")[1].split("async function load(")[0]
assert "paintHead(next);" in poll and "paintList();" in poll and "poll(next);" in poll
+1 -1
View File
@@ -1,5 +1,5 @@
"""일위대가 실무자료 이음매(PLAN 329 · 330) — 실제 서버 응답(북부청 격자 · 호표 맞춤)을
화면 도우미(`helper_m02_hopyo_seam.cjs` → Hopyo_Sheet · Hopyo_View)에 먹임.
화면 도우미(`helper_m02_hopyo_seam.cjs` → Hopyo_Sheet)에 먹임.
행 22 → 제3호표 · 25 빈 줄 · 26 → 제4호표 · 영역 B20:O24 · 단산93 행 4229:4244 ·
엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 · 도우미는 고르기 창 `Hopyo_Choose`.
+3 -3
View File
@@ -1,9 +1,9 @@
"""저장 안 한 고침 3단추 물음(PLAN 86-1 · `ui_template_leave_dialog.ts`) — Node 로 TS 를 돌려 판정.
못박는 것
- 단추 순서 [취소] [버리고 넘어감] [저장 후 넘어감] · 기본 초점 = 취소 · Esc = 취소.
- 저장 후 넘어감: 저장 성공 + 고침 없음 = saved / 저장 던짐 · 고침 남음 = stay(머묾).
- 버리고 넘어감: discard 갈고리 호출 · 취소: 아무것도 안 부름.
- 단추 순서 [취소] [폐기] [저장] · 기본 초점 = 취소 · Esc = 취소.
- 저장: 저장 성공 + 고침 없음 = saved / 저장 던짐 · 고침 남음 = stay(머묾).
- 폐기: discard 갈고리 호출 · 취소: 아무것도 안 부름.
- M02 자리(UI_Main · Structure · ItemCards)가 이 물음을 씀.
"""
+2 -2
View File
@@ -2,7 +2,7 @@
① `delayedShow` 를 Node 로 가짜 시계로 돌림 — 0.3초 전에 끝나면 안 보임(깜빡임 없음) · 넘으면 보이고
끝나면 걷힘 · 끝낸 뒤 시계가 와도 안 보임.
② 실패 자리 = 까닭 글(「불러오지 못함 — 까닭」) + [다시 불러오기] · 화면 전체 잠금 안 씀.
② 실패 자리 = 까닭 글(「불러오지 못함 — 까닭」) + [새로고침] · 화면 전체 잠금 안 씀.
③ M02 나무 · 상세(표 · 도면) · 조합 목록 · 조합 상세 · 항목 카드가 이 부품을 씀.
"""
@@ -70,7 +70,7 @@ def test_실패_글과_다시_불러오기_M02_자리():
assert "clear: (id) => clearTimeout(id)," in src
locale = _text(ROOT / "ui_template" / "ui_template_locale_common.ts")
assert 'Common_Load_Failed: ["불러오지 못함"' in locale
assert 'Common_Reload: ["다시 불러오기"' in locale
assert 'Common_Reload: ["새로고침"' in locale
tree = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
assert "const stop = showLoading(body);" in tree
assert "loadingError(why(error), () => void refresh())" in tree
+1 -1
View File
@@ -93,7 +93,7 @@ export function createFrameTemplateEditor<D extends CadHostDrawing>(
* 되돌릴 길이 없으면 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다.
*/
const resetButton = createButton({
label: "기본 도각으로",
label: "기본 도각 복원",
variant: "ghost",
onClick: () => void resetToDefault(),
});
+2 -2
View File
@@ -72,10 +72,10 @@ export interface CadTabs<D> {
const TEXT = {
add: "새 장",
rename: "두 번 눌러 이름 바꾸기",
remove: "장 지우기",
remove: "장 삭제",
close: "닫기",
removeAsk: "「{name}」 장을 지울까요? 저장하면 되돌릴 수 없음",
removeOk: "지우기",
removeOk: "삭제",
closeAsk: "「{name}」 에 저장 안 한 고침이 있음 — 버리고 닫을까요?",
closeOk: "버리고 닫기",
emptyName: "장 이름을 넣을 것",
+1 -1
View File
@@ -7,7 +7,7 @@ import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Col_Add: ["열 추가", "Add column"],
Col_Delete: ["열 지우기", "Delete column"],
Col_Delete: ["열 삭제", "Delete column"],
Col_New: ["새 열", "New column"],
Row_Unit: ["단위", "Unit"],
Row_Unit_Price: ["일위대가", "Unit price"],
+2 -2
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* ui_template_leave_dialog.ts
* 저장 안 한 고침이 있을 때 넘어가기 전 물음 — 단추 셋: [취소] [버리고 넘어감] [저장 후 넘어감](주 단추).
* 저장 안 한 고침이 있을 때 넘어가기 전 물음 — 버튼 셋: [취소] [폐기] [저장](주 버튼).
* 기본 초점 = 취소 · Esc = 취소 · 저장이 실패하거나 고침이 남으면 머묾.
* 몸은 공용 확인 창(`showConfirmDialog`)과 같은 틀(.ui-confirm).
* ========================================================================== */
@@ -57,7 +57,7 @@ export interface LeaveHooks {
save: () => Promise<unknown>;
/** 저장 뒤에도 고침이 남았나 — 남으면 머묾 */
stillDirty: () => boolean;
/** [버리고 넘어감] 때 */
/** [폐기] 때 */
discard?: () => void | Promise<void>;
}
+3 -3
View File
@@ -181,11 +181,11 @@ export const ui_locales_a = {
A06_Login_Error_Email: ["이메일 형식이 올바르지 않습니다.", "Invalid email format."],
A06_Login_Field_Otp: ["이메일 인증 코드", "Email verification code"],
A06_Login_Field_Otp_Placeholder: ["6자리 코드를 입력하세요", "Enter the 6-digit code"],
A06_Login_Verify: ["인증하고 로그인", "Verify and sign in"],
A06_Login_Verify: ["인증", "Verify"],
A06_Login_OtpSent: ["인증 코드를 이메일로 발송했습니다.", "A verification code was emailed."],
A06_Login_OtpResend: ["인증 코드 재발송", "Resend verification code"],
A06_Login_OtpResendCountdown: ["{seconds}초 후 재발송", "Resend in {seconds}s"],
A06_Login_OtpBack: ["이메일/비밀번호 다시 입력", "Re-enter email/password"],
A06_Login_OtpBack: ["재입력", "Re-enter"],
A06_Login_Success: ["로그인되었습니다.", "Signed in."],
A06_Login_Error_Request: ["로그인 요청에 실패했습니다.", "Sign-in request failed."],
@@ -209,7 +209,7 @@ export const ui_locales_a = {
A07_Register_Field_Phone: ["전화번호", "Phone number"],
A07_Register_Field_Phone_Placeholder: ["010-1234-5678", "010-1234-5678"],
A07_Register_Submit: ["회원가입", "Sign up"],
A07_Register_ToLogin: ["이미 계정이 있으신가요? 로그인", "Already have an account? Sign in"],
A07_Register_ToLogin: ["로그인", "Sign in"],
A07_Register_Error_Required: [
"모든 필수 항목을 입력하세요.",
"Please fill in all required fields.",
+4 -4
View File
@@ -21,7 +21,7 @@ export const ui_locales_b1 = {
"프로젝트가 없어졌습니다 · 대시보드에서 다시 고르세요.",
"This project no longer exists. Choose a project again on the dashboard.",
],
B_Project_Gone_ToDashboard: ["대시보드로", "Go to dashboard"],
B_Project_Gone_ToDashboard: ["대시보드", "Dashboard"],
/* --- B01_Dashboard 계정 관리 공통 문구 --- */
B01_Account_Title: ["내 계정", "My Account"],
@@ -62,7 +62,7 @@ export const ui_locales_b1 = {
B01_Dashboard_Company: ["회사 정보", "Company"],
B01_Dashboard_NoCompany: ["회사 미연결", "No company linked"],
B01_Dashboard_CreateCompany: ["회사 생성", "Create company"],
B01_Dashboard_FindCompany: ["회사 찾기", "Find company"],
B01_Dashboard_FindCompany: ["회사 검색", "Find company"],
B01_Dashboard_JoinCompany: ["가입 신청", "Request to join"],
B01_Dashboard_Members: ["팀원 관리", "Team members"],
B01_Dashboard_AddMember: ["팀원 추가", "Add member"],
@@ -352,7 +352,7 @@ export const ui_locales_b1 = {
B03_File_Status_Detected: ["중단된 업로드 감지", "Paused upload detected"],
B03_File_Restore_State: ["저장된 업로드/분석 상태 복구", "Restored upload/analysis state"],
B03_File_Resume_Button: ["업로드 재개", "Resume upload"],
B03_File_New_Button: ["새 파일로 시작", "Start new file"],
B03_File_New_Button: ["새 파일", "New file"],
B03_File_Overview_Complete: [
"필수 파일 업로드와 지표면 분석이 모두 완료된 프로젝트입니다. 파일을 다시 올리면 기존 결과가 교체됩니다.",
"All required files are uploaded and surface analysis is complete. Re-uploading replaces existing results.",
@@ -366,7 +366,7 @@ export const ui_locales_b1 = {
"이 슬롯에는 이미 업로드된 파일이 있습니다. 새 파일을 올리면 기존 파일과 그에 따른 분석 결과(지표면 모델 등)가 교체됩니다. 계속하시겠습니까?",
"This slot already has an uploaded file. Uploading a new one replaces the existing file and derived analysis results (surface model, etc.). Continue?",
],
B03_File_Replace_Confirm: ["교체하고 계속", "Replace and continue"],
B03_File_Replace_Confirm: ["교체", "Replace"],
B03_File_Replace_Cancel: ["취소", "Cancel"],
B03_File_ServiceWorker_Ready: [
"백그라운드 업로드 준비가 완료되었습니다.",
+3 -3
View File
@@ -1031,9 +1031,9 @@ export const ui_locales_b2 = {
"아직 확정된 지표면이 없습니다. 필수 파일을 올리면 지표면 분석이 자동으로 진행되고, 끝나면 이 화면이 자료를 준비합니다.",
"No confirmed surface yet. Upload the required files — the surface analysis runs automatically, and this screen prepares the data once it finishes.",
],
B11_Loading_Btn_FileInput: ["파일 입력 화면으로", "Go to file input"],
B11_Loading_Btn_Continue: ["그래도 화면으로 이동", "Continue anyway"],
B11_Loading_Btn_Dashboard: ["대시보드로", "Back to dashboard"],
B11_Loading_Btn_FileInput: ["파일 입력", "File input"],
B11_Loading_Btn_Continue: ["계속", "Continue"],
B11_Loading_Btn_Dashboard: ["대시보드", "Dashboard"],
B11_Status_Flow_Title: ["결재 진행 상태", "Payment Progress"],
B11_Status_Step_Request: ["발행 요청", "Invoice Requested"],
B11_Status_Step_Issue: ["세금계산서 발행", "Invoice Issued"],
+2 -2
View File
@@ -32,7 +32,7 @@ export const ui_locales_b3 = {
"사유는 아래 단계(수량·단가)가 낸 것을 그대로 옮김 — 여기서 새로 짓지 않음",
"Reasons are passed through from the quantity/price step as-is",
],
B09_Sheet_Reload: ["다시 불러오기", "Reload"],
B09_Sheet_Reload: ["새로고침", "Reload"],
B09_Sheet_Level: ["보이는 레벨", "Show levels"],
B09_Sheet_Level_All: ["모두", "All"],
B09_Sheet_Level_Upto: ["레벨까지", " level(s)"],
@@ -145,7 +145,7 @@ export const ui_locales_b3 = {
B01_Dashboard_PermCompanyRead: ["회사 읽기", "Company read"],
B01_Dashboard_PermCompanyWrite: ["회사 수정", "Company edit"],
B01_Dashboard_PermPersonalWrite: ["개인 수정", "Personal edit"],
B01_Dashboard_PickCompany: ["회사 고르기", "Pick company"],
B01_Dashboard_PickCompany: ["회사 선택", "Pick company"],
B01_Dashboard_PickCompanyHint: ["회사 이름 · 사업자번호", "Company name · business number"],
B01_Dashboard_PickCompanyCount: ["{count}곳", "{count} companies"],
B01_Dashboard_PickCompanyMore: [
+23 -22
View File
@@ -40,7 +40,7 @@ export const ui_locales_common = {
* ------------------------------------------------------------------------ */
Common_Status_Loading: ["불러오는 중...", "Loading..."],
Common_Load_Failed: ["불러오지 못함", "Failed to load"],
Common_Reload: ["다시 불러오기", "Reload"],
Common_Reload: ["새로고침", "Reload"],
Common_Status_Saving: ["저장 중...", "Saving..."],
Common_Status_Processing: ["처리 중...", "Processing..."],
Common_Status_Success: ["완료되었습니다", "Completed"],
@@ -117,16 +117,17 @@ export const ui_locales_common = {
App_Settings_Wheel: ["휠 확대 방향(모든 화면)", "Wheel zoom-in direction (all views)"],
App_Settings_Wheel_Pull: ["당기면 확대(휠을 몸 쪽으로)", "Pull to zoom in (wheel toward you)"],
App_Settings_Wheel_Push: ["밀면 확대(휠을 앞으로)", "Push to zoom in (wheel away)"],
App_Settings_Rules: ["마우스 단추", "Mouse buttons"],
App_Settings_Rule_Pan: ["화면 옮기기 = 가운데 단추 끌기", "Pan = drag with the middle button"],
App_Settings_Rule_Pick: ["고르기 = 왼쪽 단추", "Select = left button"],
/* 아래 규칙 값 = 한 줄에 「입력 = 동작」 · 줄바꿈(\n)으로 줄 나눔 · ko · en 줄 수 같게 */
App_Settings_Rules: ["마우스 버튼", "Mouse buttons"],
App_Settings_Rule_Pan: ["가운데 버튼 끌기 = 화면 옮김", "Middle button drag = pan"],
App_Settings_Rule_Pick: ["왼쪽 버튼 = 고르기", "Left button = select"],
App_Settings_Rule_Right: [
"오른쪽 단추 = 메뉴 · CAD 에서는 명령 중 Enter · 쉴 때 반복 · Shift = 스냅 메뉴 · CAD 휠 두 번 = 범위",
"Right button = menu · in CAD Enter during a command · repeat when idle · Shift = snap menu · CAD wheel double-click = extents",
"오른쪽 버튼 = 메뉴\n오른쪽 버튼(CAD 명령 중) = Enter\n오른쪽 버튼(CAD 쉴 때) = 반복\nShift+오른쪽 버튼(CAD) = 스냅 메뉴\n휠 두 번 누름(CAD) = 범위",
"Right button = menu\nRight button (CAD, during a command) = Enter\nRight button (CAD, idle) = repeat\nShift+right button (CAD) = snap menu\nWheel double-click (CAD) = extents",
],
App_Settings_Rule_Pinch: [
"트랙패드 · 손가락 벌림 = 늘 확대(휠 방향과 상관없음)",
"Trackpad · finger spread = always zoom in (whatever the wheel direction)",
"트랙패드 손가락 벌림 = 늘 확대(휠 방향 무관)",
"Trackpad finger spread = always zoom in (any wheel direction)",
],
App_Settings_Cad_Help: [
"CAD 는 명령 이름 > 내 별칭 > 기본 별칭 차례로 찾음 · 영문 · 숫자만 · 여러 개는 쉼표로",
@@ -144,38 +145,38 @@ export const ui_locales_common = {
App_Settings_Leave: ["저장 안 한 설정이 있음", "You have unsaved settings"],
App_Settings_Keys_All: ["모든 화면", "All views"],
App_Settings_Keys_All_Keys: [
"Esc = ☰ · 창 닫기 · 고름 풀기 · Z 누른 채 또는 오른쪽 0.4초 누름 = 돋보기(휠 = 배율)",
"Esc = ☰ · close window · clear selection · hold Z or right button 0.4 s = loupe (wheel = zoom)",
"Esc = ☰ · 창 닫기 · 고름 풀기\nZ 누른 채 = 돋보기\n오른쪽 버튼 0.4초 누름 = 돋보기\n돋보기 중 휠 = 배율",
"Esc = ☰ · close window · clear selection\nHold Z = loupe\nRight button 0.4 s = loupe\nWheel in loupe = zoom",
],
App_Settings_Keys_Profile: ["B05 종단", "B05 profile"],
App_Settings_Keys_Profile_Keys: [
"Ctrl+Z 되돌림 · Ctrl+Shift+Z 다시 · 방향키 = 옮김 · 자유지정 Enter = 끝 · Esc · Backspace",
"Ctrl+Z undo · Ctrl+Shift+Z redo · arrows = move · free mode Enter = finish · Esc · Backspace",
"Ctrl+Z = 되돌림\nCtrl+Shift+Z = 다시\n방향키 = 옮김\nEnter(자유지정) = 끝\nEsc · Backspace",
"Ctrl+Z = undo\nCtrl+Shift+Z = redo\nArrows = move\nEnter (free mode) = finish\nEsc · Backspace",
],
App_Settings_Keys_RouteEdit: ["B05 노선 편집", "B05 route edit"],
App_Settings_Keys_RouteEdit_Keys: [
"Ctrl+Z 되돌림 · Ctrl+Y · Ctrl+Shift+Z 다시",
"Ctrl+Z undo · Ctrl+Y · Ctrl+Shift+Z redo",
"Ctrl+Z = 되돌림\nCtrl+Y = 다시\nCtrl+Shift+Z = 다시",
"Ctrl+Z = undo\nCtrl+Y = redo\nCtrl+Shift+Z = redo",
],
App_Settings_Keys_Section: ["B06 횡단", "B06 cross section"],
App_Settings_Keys_Section_Keys: [
"방향키 · ▲▼ = 측점 옮김 · Enter · Space = 고름 · Esc",
"Arrows · ▲▼ = move station · Enter · Space = pick · Esc",
"방향키 = 측점 옮김\n▲▼ = 측점 옮김\nEnter = 고름\nSpace = 고름\nEsc",
"Arrows = move station\n▲▼ = move station\nEnter = pick\nSpace = pick\nEsc",
],
App_Settings_Keys_Cad: ["CAD", "CAD"],
App_Settings_Keys_Cad_Keys: [
"F3 스냅 · F7 그리드 · F8 직교 · F10 45° · Ctrl+Z · Ctrl+Y · Ctrl+A · Ctrl+S(M02) · Delete · Esc · Enter · Space = Enter · ↑↓ = 기록 · 최근 값 · Ctrl+C · X · 1 · ← → · Shift+↑↓ = 화면 옮김 · 글자 = 명령",
"F3 snap · F7 grid · F8 ortho · F10 45° · Ctrl+Z · Ctrl+Y · Ctrl+A · Ctrl+S (M02) · Delete · Esc · Enter · Space = Enter · ↑↓ = history · recent values · Ctrl+C · X · 1 · ← → · Shift+↑↓ = pan · letters = command",
"F3 = 스냅\nF7 = 그리드\nF8 = 직교\nF10 = 45°\nCtrl+Z = 되돌림\nCtrl+Y = 다시\nCtrl+S(M02) = 저장\nSpace = Enter\n↑↓ = 기록 · 최근 값\nShift+↑↓ = 화면 옮김\n글자 = 명령\nCtrl+A · Delete · Esc · Enter · Ctrl+C · X · 1 · ← →",
"F3 = snap\nF7 = grid\nF8 = ortho\nF10 = 45°\nCtrl+Z = undo\nCtrl+Y = redo\nCtrl+S (M02) = save\nSpace = Enter\n↑↓ = history · recent values\nShift+↑↓ = pan\nLetters = command\nCtrl+A · Delete · Esc · Enter · Ctrl+C · X · 1 · ← →",
],
App_Settings_Keys_Table: ["B08 · B09 표", "B08 · B09 tables"],
App_Settings_Keys_Table_Keys: [
"Enter · F2 = 입력 · 방향키 = 칸 옮김 · Esc = 그만",
"Enter · F2 = edit · arrows = move cell · Esc = cancel",
"Enter = 입력\nF2 = 입력\n방향키 = 칸 옮김\nEsc = 그만",
"Enter = edit\nF2 = edit\nArrows = move cell\nEsc = cancel",
],
App_Settings_Keys_M02: ["M02 양식", "M02 templates"],
App_Settings_Keys_M02_Keys: [
"Ctrl+S 저장 · Ctrl+C · Ctrl+V · Delete · Esc · Space+끌기 = 화면 옮김 · Ctrl+휠 = 배율 · 시트 = 엑셀 키",
"Ctrl+S save · Ctrl+C · Ctrl+V · Delete · Esc · Space+drag = pan · Ctrl+wheel = zoom · sheet = Excel keys",
"Ctrl+S = 저장\nSpace+끌기 = 화면 옮김\nCtrl+휠 = 배율\n시트 안 = 엑셀 키 그대로\nCtrl+C · Ctrl+V · Delete · Esc",
"Ctrl+S = save\nSpace+drag = pan\nCtrl+wheel = zoom\nInside a sheet = Excel keys\nCtrl+C · Ctrl+V · Delete · Esc",
],
App_Footer_Copyright: [
"© 2026 임도설계 및 견적 자동화. All rights reserved.",
+7 -7
View File
@@ -10,7 +10,7 @@ export const ui_locales_m1 = {
M01_AdminOnly: ["시스템 관리자만 볼 수 있음", "System administrators only"],
M01_LoadFailed: ["마스터 요소를 못 읽음", "Failed to load master elements"],
M01_PickFile: ["왼쪽에서 그룹과 파일을 고를 것", "Pick a group and a file on the left"],
M01_Search: ["이름 찾기", "Find by name"],
M01_Search: ["이름 검색", "Find by name"],
M01_WideHint: ["표가 넓음 — 옆으로 밀어서 볼 것", "Wide table — swipe sideways"],
M01_Prev: ["이전", "Prev"],
M01_Next: ["다음", "Next"],
@@ -18,7 +18,7 @@ export const ui_locales_m1 = {
M01_Rows: ["줄", "rows"],
M01_NoRows: ["줄 없음", "No rows"],
M01_RowAdd: ["줄 추가", "Add row"],
M01_RowDelete: ["지우기", "Delete"],
M01_RowDelete: ["삭제", "Delete"],
M01_RowRestore: ["되살리기", "Restore"],
M01_RowRemove: ["빼기", "Remove"],
M01_BackToTables: ["← 표 목록", "← Tables"],
@@ -35,25 +35,25 @@ export const ui_locales_m1 = {
"그 사이 파일이 바뀜({value}) — 고친 것을 버리고 다시 읽은 뒤 다시 고칠 것",
"Files changed meanwhile ({value}) — discard and reload, then edit again",
],
M01_StaleReload: ["버리고 다시 읽기", "Discard and reload"],
M01_StaleReload: ["새로고침", "Reload"],
M01_FileStale: [
"고치기 시작한 뒤 이 파일이 바뀜 — 저장하면 막힘",
"This file changed after you started editing — saving will be refused",
],
M01_CheckErrors: ["검사에 걸림", "Validation failed"],
M01_ProcureSearch: ["이름·규격 찾기", "Find by name or spec"],
M01_ProcureSearch: ["이름·규격 검색", "Find by name or spec"],
M01_ProcureCut: ["연결 끊기", "Unlink"],
M01_ProcureClose: ["닫기", "Close"],
M01_PriceFind: ["가격 연결", "Link price"],
M01_PriceTitle: ["가격 연결 고르기", "Pick price source"],
M01_PriceCol: ["가격 연결", "Price link"],
M01_PriceCond: ["후보 조건으로 연결 (지역은 입력)", "Link by condition (region as input)"],
M01_PriceCond: ["조건 연결 (지역은 입력)", "Link by condition (region as input)"],
M01_PriceCondName: ["이름", "Name"],
M01_PriceCondSpec: ["규격", "Spec"],
M01_PriceCondUsed: ["후보 조건", "Condition"],
M01_Govt: ["관급", "Govt"],
M01_OnlyUnlinked: ["못 이은 줄만 보기", "Unlinked only"],
M01_JobFind: ["직종 고르기", "Pick job"],
M01_OnlyUnlinked: ["미연결만", "Unlinked only"],
M01_JobFind: ["직종 선택", "Pick job"],
M01_JobTitle: ["준용 직종 고르기", "Pick substitute job"],
M01_JobCol: ["준용", "Substitute"],
M01_All: ["전체", "All"],
+3 -3
View File
@@ -101,7 +101,7 @@ export const ui_locales_m2 = {
M02_SourcePractice: ["실무문서", "Practice documents"],
M02_PeopleEmpty: ["비어 있음", "empty"],
M02_TreeEmptyEdit: [
"항목이 없음 — 빈 자리 오른쪽 클릭 또는 층 단추 옆 「+」 로 폴더 · 항목을 더함",
"항목이 없음 — 빈 자리 오른쪽 클릭 또는 층 버튼 옆 「+」 로 폴더 · 항목을 더함",
"No items — right-click an empty area or use + beside the layer buttons to add",
],
M02_TreeAddTop: ["맨 위에 폴더 · 항목 추가", "Add folder or item at top"],
@@ -155,8 +155,8 @@ export const ui_locales_m2 = {
"저장 안 한 표 고침이 있음 — 나무를 고치기 전에 처리",
"Unsaved table edits — settle them before changing the tree",
],
M02_TreeSaveGo: ["저장 후 넘어감", "Save and continue"],
M02_TreeDiscardGo: ["버리고 넘어감", "Discard and continue"],
M02_TreeSaveGo: ["저장", "Save"],
M02_TreeDiscardGo: ["폐기", "Discard"],
M02_TreeDone: ["나무를 고침", "Tree updated"],
M02_TreeRenamed: [
"나무를 고침 — 같은 이름이 있어 「{value}」 로 저장",