Merge remote-tracking branch 'origin/dev' into sub_laptop_5
This commit is contained in:
+20
-10
@@ -471,39 +471,45 @@ html:is([data-burger="on"], .is-bm-route) .app-burger { display: inline-flex; }
|
||||
.app-header.is-drawer-open .app-drawer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
gap: var(--spacing-12);
|
||||
position: fixed;
|
||||
top: var(--app-header-h);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(240px, 100vw);
|
||||
padding: var(--spacing-16);
|
||||
padding: var(--spacing-16) 0;
|
||||
overflow-y: auto;
|
||||
background: var(--color-surface);
|
||||
border-left: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
/* 서랍 간격 8/12 — 묶음 안 8 · 묶음 사이 12 + 선 + 12 · 줄 높이 마우스 40 · 손가락 44 · 글 왼쪽 16(389-2) */
|
||||
.app-drawer__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
.app-drawer__nav > * { justify-content: flex-start; min-height: 44px; }
|
||||
.app-drawer__nav > * { justify-content: flex-start; margin-inline: 8px; padding-left: 7px; min-height: var(--spacing-40); }
|
||||
.app-drawer__site { display: block; }
|
||||
.app-drawer__site > summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; font-weight: 600; }
|
||||
.app-drawer__site > nav { margin-top: var(--spacing-4); }
|
||||
.app-drawer__site > summary { min-height: var(--spacing-40); display: flex; align-items: center; padding-left: 16px; cursor: pointer; font-weight: 600; }
|
||||
.app-drawer__site > nav { margin-top: var(--spacing-8); }
|
||||
.app-drawer__steps .ui-workflow-layout__step { min-height: var(--spacing-40); margin-inline: 8px; width: auto; padding-left: 7px; }
|
||||
.app-drawer__steps .ui-workflow-layout__step--home { margin-bottom: 0; }
|
||||
html[data-touch="on"] :is(.app-drawer__nav > *, .app-drawer__site > summary, .app-drawer__steps .ui-workflow-layout__step, .app-drawer__row > *) { min-height: 44px; }
|
||||
.app-drawer__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
padding-inline: 16px;
|
||||
}
|
||||
.app-drawer__row > * { min-height: var(--spacing-40); }
|
||||
.app-drawer__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
.app-drawer__group + .app-drawer__group {
|
||||
padding-top: var(--spacing-16);
|
||||
padding-top: var(--spacing-12);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.app-drawer__steps:empty { display: none; }
|
||||
@@ -511,9 +517,11 @@ html:is([data-burger="on"], .is-bm-route) .app-burger { display: inline-flex; }
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding-left: 16px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
.app-drawer__touch .app-drawer__row { padding-inline: 0; }
|
||||
.app-drawer__touch [aria-checked="true"] {
|
||||
background: var(--color-mist-violet);
|
||||
color: var(--color-accent);
|
||||
@@ -562,11 +570,13 @@ html.is-bm-route .app-header.is-drawer-open .app-drawer {
|
||||
border-left: 0;
|
||||
border-right: 2px solid var(--color-accent);
|
||||
}
|
||||
.app-drawer__brand { flex-direction: row; align-items: center; min-height: 44px; padding-left: 16px; }
|
||||
html.is-bm-route .app-header.is-drawer-open .app-drawer { padding-top: var(--spacing-8); }
|
||||
html.is-bm-route .app-drawer__brand {
|
||||
min-height: 44px;
|
||||
padding-left: calc(var(--app-burger-inset) - var(--spacing-16));
|
||||
min-height: var(--app-burger-w);
|
||||
padding-left: var(--app-burger-inset);
|
||||
}
|
||||
html.is-bm-route .app-drawer__brand .app-brand { align-self: flex-end; }
|
||||
html.is-bm-route[data-touch="on"] .app-drawer__brand { min-height: 44px; }
|
||||
`;
|
||||
|
||||
export function injectShellStyles(): void {
|
||||
|
||||
@@ -53,11 +53,8 @@ export const STATE_REGISTRY = {
|
||||
legacy: () => "b05-route-profile-collapsed",
|
||||
},
|
||||
"profile-height": { bucket: "pref", scope: "global", legacy: () => "b05-route-profile-height" },
|
||||
"drainage-collapsed": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
legacy: () => "b05-route-drainage-collapsed",
|
||||
},
|
||||
/** 배수유역 판 접힘 — 기억 없음 = 닫힘(392-1 · 옛 "false" 기억 버림). */
|
||||
"drainage-collapsed": { bucket: "pref", scope: "global", version: 2 },
|
||||
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
|
||||
/** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */
|
||||
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
|
||||
@@ -77,6 +74,8 @@ export const STATE_REGISTRY = {
|
||||
"routeedit-locks": { bucket: "pref", scope: "project" },
|
||||
/** B05 계획노선 편집 창 — 분석 지형(las · sheet). 확정 지표면은 안 바꿈 */
|
||||
"routeedit-terrain": { bucket: "pref", scope: "project" },
|
||||
/** B05 계획노선 편집 창 — 측점 스냅 간격(0.1 · 1 · 10 · station) */
|
||||
"routeedit-snap-step": { bucket: "pref", scope: "project" },
|
||||
"masshaul-open": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
|
||||
@@ -55,13 +55,20 @@
|
||||
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 {
|
||||
@@ -72,7 +79,6 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -75,7 +75,7 @@ export function part<T>(ctx: DesignPanelContext, name: string): T {
|
||||
export function createSectionFrame(
|
||||
view: DesignView,
|
||||
title: string,
|
||||
collapsed = false,
|
||||
collapsed = true,
|
||||
): { root: HTMLElement; body: HTMLElement } {
|
||||
const root = document.createElement("section");
|
||||
const heading = document.createElement("h3");
|
||||
|
||||
@@ -145,8 +145,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
// 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도
|
||||
// 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고).
|
||||
bodyWrap.append(balanceBar, body, progress.root);
|
||||
// 유토곡선 — 종단 칸 아래 자기 판(354-1) · 테이블 — 종단 칸 바닥 겹침 판.
|
||||
// 둘 다 위 경계 리사이저로 높이를 조절한다.
|
||||
// 유토곡선 · 테이블 — 종단 칸 바닥 겹침 판(2026-08-04 · 392-2) · 위 경계 리사이저로 높이 조절.
|
||||
// 서브패널 리사이저를 끄는 동안엔 메인 드래그와 같은 **경량 동기화**만 프레임마다 돌리고
|
||||
// (차트 SVG·테이블 재생성은 무거워 덜컹였다 — 2026-08-06 분석 원인 3), 전체 재구성은
|
||||
// 손을 뗀 뒤(clearDragFlags) 1회만 한다. 펼침·범례 토글 등 드래그 밖 알림은 전체 draw.
|
||||
@@ -155,7 +154,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
else draw();
|
||||
};
|
||||
// 유토곡선 · 테이블 오버레이 · 유토 배지는 `design_profile_overlays` 몫(700줄 한계).
|
||||
const { massHaul, tableOverlay, column } = createProfileOverlays({
|
||||
const { massHaul, tableOverlay } = createProfileOverlays({
|
||||
projectId,
|
||||
routeId: () => routeId,
|
||||
bodyWrap,
|
||||
@@ -192,7 +191,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
callbacks?.onStructureTypeAdd?.(chainage, typeId),
|
||||
),
|
||||
}) ?? null;
|
||||
content.append(column);
|
||||
content.append(bodyWrap);
|
||||
if (drainagePanel) content.append(drainagePanel.root);
|
||||
const drag = createProfileDrag({
|
||||
root,
|
||||
@@ -386,7 +385,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
|
||||
/**
|
||||
* 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지
|
||||
* 줄고 → ② 테이블이 최소까지 줄고(유토곡선은 자기 판 · 354-1) → ③ 둘 다 최소면
|
||||
* 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면
|
||||
* 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기
|
||||
* 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리).
|
||||
*
|
||||
@@ -396,6 +395,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
const heights = createHeightCascade({
|
||||
root,
|
||||
body,
|
||||
massHaul,
|
||||
tableOverlay,
|
||||
isMainDragging: drag.isMainDragging,
|
||||
isSubDragging: drag.isSubDragging,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* =============================================================================
|
||||
* design_profile_overlays.ts
|
||||
* 종단도 겹침 판(테이블) · 종단 열 아래 유토곡선 판 · 유토 총괄 배지(213-3 · 700줄 한계로 뗌).
|
||||
* 만들어 붙이고 종단 스크롤과 잇는 데까지 — 그리기 · 높이는 종단도 몫.
|
||||
* 종단 열(`column`) = [종단 칸(`bodyWrap`), 유토곡선 판](354-1) — 종단도가 content 왼쪽에 붙인다.
|
||||
* 종단도 바닥 오버레이 서브패널 — 유토곡선 · 테이블 · 유토 총괄 배지(213-3 · 700줄 한계로 뗌).
|
||||
* 만들어 `bodyWrap` 에 붙이고 종단 스크롤과 잇는 데까지 — 그리기 · 높이는 종단도 몫.
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachMassHaulBadgeDrag, createMassHaulBadge } from "@util/common_util_mass_haul_badge";
|
||||
@@ -17,7 +16,7 @@ import {
|
||||
export interface ProfileOverlaysDeps {
|
||||
projectId: string;
|
||||
routeId: () => number | null;
|
||||
/** 종단 칸(요약 줄 · 본문 · 서클을 담은 칸) — 표 겹침 판이 그 위를 덮는다. */
|
||||
/** 종단 열(요약 줄 · 본문 · 서클을 담은 칸) — 오버레이가 그 위를 덮는다. */
|
||||
bodyWrap: HTMLElement;
|
||||
/** 종단 스크롤러 — 오버레이 가로 스크롤을 이것과 맞춘다. */
|
||||
body: HTMLElement;
|
||||
@@ -55,11 +54,8 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) {
|
||||
},
|
||||
haulPrefetch,
|
||||
);
|
||||
// 유토곡선 = 종단 칸 아래 자기 판(354-1 · 배수유역도처럼) — 종단 [창 분할]에도 함께 감(content 안).
|
||||
const column = document.createElement("div");
|
||||
column.className = "b05-route-profile__column";
|
||||
column.append(bodyWrap, massHaul.overlay);
|
||||
// 테이블은 종단 칸 바닥 겹침 판(2026-08-05 사용자 지시).
|
||||
bodyWrap.append(massHaul.overlay, massHaul.handle);
|
||||
// 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시).
|
||||
const tableOverlay = createProfileTableOverlay(onChange);
|
||||
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
|
||||
tableOverlay.attachScrollSync(body);
|
||||
@@ -67,5 +63,5 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) {
|
||||
// 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다.
|
||||
body.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
return { massHaul, tableOverlay, column };
|
||||
return { massHaul, tableOverlay };
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export function attachProfilePopout(
|
||||
});
|
||||
}
|
||||
|
||||
/** 유토곡선 · 배수유역 판에 [창 분할] 단추를 단다 — 두 판 머리줄 맨 앞(계획서 325-3 · 354-1). */
|
||||
/** 유토곡선 · 배수유역 판에 [창 분할] 단추를 단다 — 유토 판 좌상단 · 배수 머리줄 맨 앞(계획서 325-3). */
|
||||
export function attachSubPanelPopouts(profile: DesignProfile): void {
|
||||
const subButton = (title: string): HTMLButtonElement => {
|
||||
const button = document.createElement("button");
|
||||
@@ -141,20 +141,23 @@ export function attachSubPanelPopouts(profile: DesignProfile): void {
|
||||
};
|
||||
|
||||
const massOverlay = profile.root.querySelector<HTMLElement>(".b05-profile__masshaul-overlay");
|
||||
const massHead = massOverlay?.querySelector<HTMLElement>(".b05-profile__masshaul-head");
|
||||
if (massOverlay && massHead) {
|
||||
const massHandle = profile.root.querySelector<HTMLElement>(".b05-profile__masshaul-handle");
|
||||
if (massOverlay && massHandle) {
|
||||
const button = subButton("유토곡선 창 분할");
|
||||
// 판 머리줄 맨 앞 — 접어도 남아 닿는다(354-1).
|
||||
massHead.prepend(button);
|
||||
const column = massOverlay.parentElement;
|
||||
massOverlay.append(button);
|
||||
attachPopout(profile, {
|
||||
piece: massOverlay,
|
||||
button,
|
||||
name: "aislo-masshaul-popout",
|
||||
features: "popup,width=1400,height=420",
|
||||
docTitle: "유토곡선 — Aislo",
|
||||
// 제자리 = 종단 열 끝(종단 칸 아래 자기 판).
|
||||
home: () => column?.append(massOverlay),
|
||||
// 제자리 = 유토 손잡이 바로 앞(종단 본문 바닥 겹침 판).
|
||||
home: () => massHandle.before(massOverlay),
|
||||
onMove: (popped) => {
|
||||
// 뗀 동안 메인 손잡이는 숨김 · 같이 줄이기 인라인 높이는 비움(새 창은 창 높이를 다 씀).
|
||||
massHandle.hidden = popped;
|
||||
massOverlay.style.height = "";
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -51,12 +51,14 @@ export function readTouchMode(): TouchMode {
|
||||
}
|
||||
}
|
||||
|
||||
const coarseQuery = window.matchMedia("(pointer: coarse)");
|
||||
// window 없는 곳(서버 Node 묶음) = 건너뜀
|
||||
const hasWindow = typeof window !== "undefined";
|
||||
const coarseQuery = hasWindow ? window.matchMedia("(pointer: coarse)") : null;
|
||||
let current: DeviceContext | null = null;
|
||||
let lastInput = "";
|
||||
|
||||
function read(): DeviceContext {
|
||||
const pointer = coarseQuery.matches ? "coarse" : "fine";
|
||||
const pointer = coarseQuery?.matches ? "coarse" : "fine";
|
||||
const touchMode = readTouchMode();
|
||||
const touch = touchMode === "on" || (touchMode === "auto" && pointer === "coarse");
|
||||
return {
|
||||
@@ -105,7 +107,7 @@ function init(): void {
|
||||
for (const [name, px] of Object.entries(SIZE_BREAKS))
|
||||
html.style.setProperty(`--bp-${name}`, `${px}px`);
|
||||
apply();
|
||||
coarseQuery.addEventListener("change", apply);
|
||||
coarseQuery?.addEventListener("change", apply);
|
||||
window.addEventListener("resize", apply);
|
||||
window.visualViewport?.addEventListener("resize", apply);
|
||||
screen.orientation?.addEventListener("change", apply);
|
||||
@@ -120,4 +122,4 @@ function init(): void {
|
||||
);
|
||||
}
|
||||
|
||||
init();
|
||||
if (hasWindow) init();
|
||||
|
||||
@@ -556,9 +556,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
panelHandle.root.addEventListener("click", () =>
|
||||
setCollapsed(!root.classList.contains("is-collapsed")),
|
||||
);
|
||||
// 상세 배수유역 정보는 페이지에 들어오면 바로 보여야 한다 — 저장값이 없으면 펼침이 기본이다
|
||||
// (같은 페이지의 하단 종단 패널과 같은 규칙).
|
||||
setCollapsed(readStateRaw("drainage-collapsed") === "true");
|
||||
// 저장값이 없으면 닫힘이 기본(392-1) — 사용자가 펼친 기억("false")만 펼친다.
|
||||
setCollapsed(readStateRaw("drainage-collapsed") !== "false");
|
||||
|
||||
return {
|
||||
root,
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
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;
|
||||
@@ -92,7 +93,7 @@ export function createElevationHold(
|
||||
};
|
||||
}
|
||||
|
||||
/** 겹침 판(표) 중 끌기가 보는 것만. */
|
||||
/** 겹침 판(유토곡선 · 표) 중 끌기가 보는 것만. */
|
||||
interface SubPanel {
|
||||
overlay: HTMLElement;
|
||||
commitHeight: (height: number) => void;
|
||||
@@ -101,8 +102,7 @@ interface SubPanel {
|
||||
export interface ProfileDragContext {
|
||||
root: HTMLElement;
|
||||
body: HTMLElement;
|
||||
/** 유토곡선 판 — 그 리사이저 끌기 출처만 본다(자기 판이라 높이 나눔에 안 낌 · 354-1). */
|
||||
massHaul: { overlay: HTMLElement };
|
||||
massHaul: SubPanel;
|
||||
tableOverlay: SubPanel & { isOpen: () => boolean };
|
||||
/** 손을 뗀 뒤 한 번 다시 세운다. */
|
||||
draw: () => void;
|
||||
@@ -112,7 +112,7 @@ export interface ProfileDrag {
|
||||
resizerRoot: HTMLElement;
|
||||
isMainDragging: () => boolean;
|
||||
isSubDragging: () => boolean;
|
||||
mainDragRef: () => { chart: number; table: number } | null;
|
||||
mainDragRef: () => { chart: number; mass: number; table: number } | null;
|
||||
isMainDragCooldown: () => boolean;
|
||||
clearMainDragCooldown: () => void;
|
||||
/** 전체 재구성을 120ms 뒤로 미룬다(연속 호출은 마지막 한 번만). */
|
||||
@@ -133,21 +133,23 @@ 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 사용자 확정) — 유토곡선 판 높이는
|
||||
// chrome(뿌리 − 본문)에 이미 들어 있다(354-1).
|
||||
// 동적 하한: 종단 최소 + 열린 서브패널들의 최소 높이 합(2026-08-05 사용자 확정).
|
||||
// 패널을 아래로 끌면 종단이 먼저 줄고 → 서브패널들이 같이 줄고(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 + tableMin,
|
||||
chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin,
|
||||
);
|
||||
},
|
||||
// 상한 = 가운데 칸 높이의 90%(위 칸 3D · 횡단 카드가 다 가려지지 않게) — Heights 와 한 식
|
||||
@@ -164,14 +166,19 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
|
||||
* 플래그가 이미 풀린 채 돌아, 드래그 중 금지했던 자동 확대가 손 떼는 순간 발동해
|
||||
* 포인터가 정한 높이를 되돌렸다(2026-08-06 분석 원인 4). draw()가 끝나며 푼다. */
|
||||
let mainDragCooldown = false;
|
||||
/** 메인 드래그 시작 시점의 두 영역(종단·테이블) 실제 높이 — 비례 연동 기준.
|
||||
/** 메인 드래그 시작 시점의 3영역(종단·유토곡선·테이블) 실제 높이 — 비례 연동 기준.
|
||||
* 드래그 중 이 비율대로 같이 늘리고 줄인다(2026-08-06 사용자 확정). */
|
||||
let mainDragRef: { chart: number; table: number } | null = null;
|
||||
let mainDragRef: { chart: number; mass: 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 - tableH - MASSHAUL_HANDLE_GUTTER_PX),
|
||||
chart: Math.max(
|
||||
MIN_CHART_HEIGHT,
|
||||
body.clientHeight - massH - tableH - MASSHAUL_HANDLE_GUTTER_PX,
|
||||
),
|
||||
mass: massH,
|
||||
table: tableH,
|
||||
};
|
||||
});
|
||||
@@ -186,7 +193,9 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
|
||||
// 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정
|
||||
// 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전
|
||||
// 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다).
|
||||
if (massDocked()) massHaul.commitHeight(massHaul.overlay.offsetHeight);
|
||||
if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight);
|
||||
massHaul.overlay.style.height = "";
|
||||
tableOverlay.overlay.style.height = "";
|
||||
}
|
||||
mainDragRef = null;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Heights.ts
|
||||
* 종단 패널의 높이 배분 — 그래프·테이블이 한 본문 높이를 나눠 갖는 규칙.
|
||||
* 유토곡선은 종단 열 아래 자기 판이라 본문 높이에서 이미 빠져 있다 — 나눔에 안 낀다(354-1).
|
||||
* 종단 패널의 높이 배분 — 그래프·유토곡선·테이블이 한 패널 높이를 나눠 갖는 규칙.
|
||||
*
|
||||
* 패널 본체(B05_Profile_UI_Profile_Panel)가 700줄 한계에 닿아 분리했다.
|
||||
* 판정 기준은 **저장 높이**(리사이저 변수)이며 화면 높이가 아니다 — 화면 높이로 재면
|
||||
@@ -9,13 +8,14 @@
|
||||
* 드래그 플래그·상세 데이터 유무는 본체가 계속 들고 있고 여기서는 접근자로만 읽는다.
|
||||
* ========================================================================== */
|
||||
|
||||
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,15 +32,21 @@ 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;
|
||||
/** 메인 드래그 시작 시점의 두 영역 비율(없으면 null). */
|
||||
mainDragRef: () => { chart: number; table: number } | null;
|
||||
/** 메인 드래그 시작 시점의 3영역 비율(없으면 null). */
|
||||
mainDragRef: () => { chart: number; mass: number; table: number } | null;
|
||||
/** 손 뗀 직후 첫 재구성까지는 자동 확대를 막는다. */
|
||||
isMainDragCooldown: () => boolean;
|
||||
/** 상세 데이터가 아직 없으면 경량 동기화를 건너뛴다. */
|
||||
@@ -61,17 +67,21 @@ 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);
|
||||
// ── 메인 드래그 중: 비례 연동(2026-08-06 사용자 확정) ─────────────
|
||||
// 드래그 시작 시점 비율(deps.mainDragRef())대로 종단·테이블을 같이 늘리고
|
||||
// ── 메인 드래그 중: 3영역 비례 연동(2026-08-06 사용자 확정) ─────────────
|
||||
// 드래그 시작 시점 비율(deps.mainDragRef())대로 종단·유토곡선·테이블을 같이 늘리고
|
||||
// 줄인다. 최소에 닿은 영역은 거기서 멈추고 남은 영역끼리 다시 비례 배분한다.
|
||||
// 손을 떼면 clearDragFlags가 이 결과를 저장 높이로 확정한다.
|
||||
const dragRef = deps.mainDragRef();
|
||||
@@ -79,12 +89,15 @@ 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 }]
|
||||
: []),
|
||||
];
|
||||
// 고정 → 재배분 반복: 축소 배율로 최소를 뚫는 항목을 최소에 고정하고, 남은
|
||||
// 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 2개라 최대 2회에 끝난다.
|
||||
// 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 3개라 최대 3회에 끝난다.
|
||||
const out: Record<string, number> = {};
|
||||
let pool = content;
|
||||
let active = items;
|
||||
@@ -103,34 +116,50 @@ 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`;
|
||||
return { chartHeight: Math.max(MIN_CHART_HEIGHT, content - tableOverlayHeight) };
|
||||
// 손잡이는 바뀐 인라인 높이의 위 경계를 즉시 따라간다.
|
||||
deps.massHaul.syncHandle();
|
||||
deps.tableOverlay.setBottomOffset(massHeight);
|
||||
return {
|
||||
chartHeight: Math.max(MIN_CHART_HEIGHT, content - massHeight - tableOverlayHeight),
|
||||
};
|
||||
}
|
||||
if (deps.isSubDragging()) {
|
||||
// 서브패널 리사이저를 끄는 중이면 그 높이가 **사용자 의도**다 — 임시 축소(인라인)를
|
||||
// 걷어 변수(드래그 값)가 그대로 보이게 하고, 자리가 모자라면 아래 deficit 처리로
|
||||
// 메인 패널을 키운다. 변수는 드래그마다 갱신되므로 저장 높이 = 드래그 값이다.
|
||||
deps.massHaul.overlay.style.height = "";
|
||||
deps.tableOverlay.overlay.style.height = "";
|
||||
} else if (tableDesired > budget) {
|
||||
// ② 줄이기 — 줄일 수 있는 여유분만큼 축소, 최소 높이 하한.
|
||||
} else if (massDesired + tableDesired > budget) {
|
||||
// ② 같이 줄이기 — 줄일 수 있는 여유분에 비례해 축소, 각자 최소 높이 하한.
|
||||
// 자리가 다시 늘면 ratio가 매끄럽게 0으로 줄어 저장 높이로 연속 복귀한다.
|
||||
const massMin = massOpen ? MASSHAUL_MIN_HEIGHT : 0;
|
||||
const tableMin = tableOpen ? TABLE_OVERLAY_MIN_HEIGHT : 0;
|
||||
const shrinkable = tableDesired - tableMin;
|
||||
const over = tableDesired - budget;
|
||||
const shrinkable = massDesired - massMin + (tableDesired - tableMin);
|
||||
const over = massDesired + 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 + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available;
|
||||
const deficit =
|
||||
MIN_CHART_HEIGHT + massHeight + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available;
|
||||
if (deficit > 1 && allowGrow && !deps.isMainDragging() && !deps.isMainDragCooldown()) {
|
||||
// 상한은 메인 리사이저 max와 **같은 식**이어야 한다 — 예전 window 92% 상한은
|
||||
// 리사이저 상한(부모 90%)보다 높아, 자동 확대 직후 손잡이를 잡는 순간 낮은
|
||||
@@ -145,7 +174,7 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
|
||||
return {
|
||||
chartHeight: Math.max(
|
||||
MIN_CHART_HEIGHT,
|
||||
available - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX,
|
||||
available - massHeight - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_MassHaul.ts
|
||||
* B05 유토곡선 — 종단 열 아래 **자기 판**(354-1 · 배수유역도처럼 손잡이 · 높이 · 접기 따로).
|
||||
* B05 유토곡선 — 종단면 패널 바닥에 붙는 **오버레이 서브패널**.
|
||||
*
|
||||
* ── B06과 같은 그림이어야 한다 (2026-08-03 사용자 확정) ──────────────
|
||||
* 이 프로그램은 파일 입력 시 기본 설정값으로 B06 몫까지 미리 계산해 둔다 — 종횡단 상세
|
||||
@@ -14,11 +14,12 @@
|
||||
* 같은 영구저장소(`longitudinal_sections.data.mass_haul`)를 읽으므로, B06에서 횡단을
|
||||
* 고쳐 임시저장/확정하면 B05는 재조회만으로 같은 곡선을 받는다.
|
||||
*
|
||||
* ── 배치 (354-1 · 요구 235-7 「유토곡선도 분리」) ─────────────────────
|
||||
* 종단 열(그래프 · 표) 아래 칸에 따로 선다 — 종단도 · 표 위를 덮지 않고 높이 나눔 · 표 쌓기에도
|
||||
* 안 낀다. 높이는 판 위 경계 리사이저(`createPanelResizer`)로 조절하고 세션 동안 유지한다.
|
||||
* 접으면 손잡이만 남는다(367-2). 곡선은 늘 노선 전체를 한 화면에 맞춰 그린다 — 종단 측점과
|
||||
* 맞물리는 보기 · 스크롤 동기는 없앰(367-1).
|
||||
* ── 배치 (2026-08-04 사용자 확정: 오버레이 전환 · 392-2 되살림) ──────────
|
||||
* 종단도·테이블 배치를 **건드리지 않고**, 패널 바닥에 붙는 오버레이가 그 위를 덮는다. 높이는
|
||||
* 오버레이 위 경계의 리사이저(메인 하단 패널과 같은 `createPanelResizer`)로 조절하고
|
||||
* 세션 동안 유지한다. 메인 패널을 끌면 종단 · 유토곡선 · 테이블이 같은 비율로 늘고 준다.
|
||||
* 곡선은 늘 노선 전체를 한 화면에 맞춰 그린다 — 종단 측점과 맞물리는 보기 · 스크롤 동기는
|
||||
* 없앰(367-1).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
@@ -61,10 +62,12 @@ 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";
|
||||
@@ -139,13 +142,21 @@ export interface RouteMassHaulDrawParams {
|
||||
}
|
||||
|
||||
export interface RouteMassHaulPanel {
|
||||
/** 판 머리줄의 접기 손잡이(펼치기/접기) — 판 안에 있다. */
|
||||
/** 종단면 패널 바닥에 붙는 2차 슬라이드 손잡이(펼치기/접기). */
|
||||
handle: HTMLElement;
|
||||
/** 유토곡선 판 — 호출한 쪽이 종단 열 아래(`b05-route-profile__column` 끝)에 붙인다. */
|
||||
/** 오버레이 서브패널 — 호출한 쪽이 `position: relative`인 패널 본문 래퍼에 붙인다. */
|
||||
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;
|
||||
/** 판 내용을 다시 그린다. 접혀 있으면 손잡이만 남기고 끝낸다. */
|
||||
/** 오버레이 내용을 다시 그린다. 접혀 있으면 오버레이만 숨기고 끝낸다. */
|
||||
draw(params: RouteMassHaulDrawParams): void;
|
||||
}
|
||||
|
||||
@@ -190,12 +201,8 @@ export function createRouteMassHaulPanel(
|
||||
handle.className = "b05-profile__masshaul-handle";
|
||||
handle.append(handleControl.root);
|
||||
handleControl.root.setAttribute("aria-label", "유토곡선 패널");
|
||||
// 머리줄 — [창 분할]([attachSubPanelPopouts] 가 맨 앞에 붙임 · 펼친 때만 보임) · 손잡이(접으면 판 위 경계에 매달림 · 367-2).
|
||||
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";
|
||||
@@ -222,7 +229,8 @@ export function createRouteMassHaulPanel(
|
||||
max: () => (overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO,
|
||||
storageKey: OVERLAY_HEIGHT_KEY,
|
||||
onResize: () => {
|
||||
// 전체 redraw는 프레임당 한 번만.
|
||||
// 손잡이는 경계를 즉시 따라가고(가벼움), 전체 redraw는 프레임당 한 번만.
|
||||
syncHandlePosition();
|
||||
if (resizeRedrawPending) return;
|
||||
resizeRedrawPending = true;
|
||||
frameOf(overlay, () => {
|
||||
@@ -237,10 +245,16 @@ export function createRouteMassHaulPanel(
|
||||
pendingChip.className = "b05-profile__masshaul-pending";
|
||||
pendingChip.textContent = "다시 계산 중…";
|
||||
pendingChip.hidden = true;
|
||||
overlay.append(resizer.root, head, bar, scroll, legendLayer, pendingChip);
|
||||
overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip);
|
||||
|
||||
/** 곡선을 그리는가 — 펼침이거나 새 창으로 뗌(뗀 창엔 접기가 없음 · 354-1). */
|
||||
const shown = (): boolean => open || overlay.classList.contains("is-popout");
|
||||
/**
|
||||
* 손잡이를 오버레이 **위 경계**에 태운다(2026-08-04 사용자 지시 — 펼치면 같이 올라와
|
||||
* 테이블 영역을 침범해도 된다). 접혀 있으면 패널 바닥으로 돌아온다 — 유토곡선은 서브패널
|
||||
* 순서상 맨 아래라 테이블이 열려 있어도 버튼은 바닥에 남는다(2026-08-05 사용자 확정).
|
||||
*/
|
||||
function syncHandlePosition(): void {
|
||||
handle.style.bottom = open ? `${overlay.offsetHeight}px` : "0px";
|
||||
}
|
||||
|
||||
function applyOpen(next: boolean): void {
|
||||
open = next;
|
||||
@@ -249,14 +263,14 @@ export function createRouteMassHaulPanel(
|
||||
// 툴팁은 setOpen이 일반 문구로 덮으므로 매번 유토곡선용으로 다시 밝힌다.
|
||||
handleControl.root.title = next ? "유토곡선 접기" : "유토곡선 펼치기";
|
||||
handle.classList.toggle("is-open", next);
|
||||
// 접으면 손잡이만 남는다(배수유역도처럼 손잡이는 늘 닿음)
|
||||
overlay.classList.toggle("is-collapsed", !next);
|
||||
overlay.hidden = !next;
|
||||
syncHandlePosition();
|
||||
onChanged();
|
||||
}
|
||||
handleControl.setOpen(open);
|
||||
handleControl.root.title = open ? "유토곡선 접기" : "유토곡선 펼치기";
|
||||
handle.classList.toggle("is-open", open);
|
||||
overlay.classList.toggle("is-collapsed", !open);
|
||||
overlay.hidden = !open;
|
||||
handleControl.root.addEventListener("click", () => applyOpen(!open));
|
||||
|
||||
function toggleSeries(key: string): void {
|
||||
@@ -321,10 +335,11 @@ export function createRouteMassHaulPanel(
|
||||
}
|
||||
|
||||
function drawNow(params: RouteMassHaulDrawParams): void {
|
||||
overlay.classList.toggle("is-collapsed", !open);
|
||||
overlay.hidden = !open;
|
||||
syncHandlePosition();
|
||||
// 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다.
|
||||
// 펼친 경우에는 아래에서 낸 계열로 총괄값을 낸다(계산 한 번).
|
||||
if (!shown()) {
|
||||
if (!open) {
|
||||
reportSummary(params);
|
||||
return;
|
||||
}
|
||||
@@ -444,7 +459,18 @@ export function createRouteMassHaulPanel(
|
||||
return {
|
||||
handle,
|
||||
overlay,
|
||||
isOpen: shown,
|
||||
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(),
|
||||
setContext(next) {
|
||||
context = next;
|
||||
},
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* 오버레이 서브패널로 감싼다(2026-08-05 사용자 지시).
|
||||
*
|
||||
* - 접힘/펼침 표준 삼각형 손잡이 + 위 경계 리사이저 + 세션 보존.
|
||||
* - 종단 칸 바닥에 붙는다 — 유토곡선은 그 아래 자기 판이라 쌓지 않는다(354-1).
|
||||
* - 테이블 내용물은 Profile_Panel의 draw()가 만들어 setTable로 넣는다.
|
||||
* - 유토곡선 오버레이가 함께 열리면 그 **위**에 쌓인다 — 위→아래 순서가
|
||||
* 종단도 → 테이블 → 유토곡선이 되도록 setBottomOffset으로 바닥 간격을 받는다.
|
||||
* - 접혔을 때 손잡이는 유토곡선 손잡이와 가로로 나란히(일렬) 놓인다(CSS에서
|
||||
* 가로 오프셋). 테이블 내용물은 Profile_Panel의 draw()가 만들어 setTable로 넣는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
@@ -52,6 +54,8 @@ export interface RouteProfileTableOverlay {
|
||||
setTable: (table: HTMLElement | null) => void;
|
||||
/** 종단 그래프 스크롤러와 가로 스크롤 양방향 동기화. */
|
||||
attachScrollSync: (main: HTMLElement) => void;
|
||||
/** 유토곡선 오버레이가 차지한 바닥 높이(px). 그 위로 쌓인다. */
|
||||
setBottomOffset: (px: number) => void;
|
||||
}
|
||||
|
||||
export function createProfileTableOverlay(onChanged: () => void): RouteProfileTableOverlay {
|
||||
@@ -69,6 +73,7 @@ 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",
|
||||
@@ -97,9 +102,11 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
|
||||
// 유토곡선 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(종단 스크롤도 함께 움직인다).
|
||||
attachWheelHorizontalScroll(scroll);
|
||||
|
||||
/** 손잡이는 오버레이 위 경계를, 접히면 종단 칸 바닥을 따라간다(2026-08-05 사용자 지시). */
|
||||
/** 손잡이는 오버레이 위 경계를, 접히면 열린 유토곡선의 위 경계(bottomOffset)를 따라간다
|
||||
* — 접힌 버튼이 펼쳐진 패널 수평선에 놓인다(2026-08-05 사용자 지시). */
|
||||
function syncHandlePosition(): void {
|
||||
handle.style.bottom = open ? `${overlay.offsetHeight}px` : "0px";
|
||||
handle.style.bottom = open ? `${bottomOffset + overlay.offsetHeight}px` : `${bottomOffset}px`;
|
||||
overlay.style.bottom = `${bottomOffset}px`;
|
||||
}
|
||||
|
||||
function applyOpen(next: boolean): void {
|
||||
@@ -147,5 +154,9 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
|
||||
syncedMain = main;
|
||||
linkScrollLeft(main, scroll);
|
||||
},
|
||||
setBottomOffset(px) {
|
||||
bottomOffset = px;
|
||||
syncHandlePosition();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
import type { DesignPageHost } from "../A00_Common/design/design_page";
|
||||
import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft";
|
||||
import { bindRouteShift } from "./B05_Profile_UI_RouteEdit_Shift";
|
||||
import { bindStationSnap, paintStationSnap } from "./B05_Profile_UI_RouteEdit_SnapStep";
|
||||
import * as fit from "./logic/B05_Profile_RouteEdit_StationFit";
|
||||
import "./B05_Profile_UI_Style_RouteEdit.css";
|
||||
|
||||
@@ -173,11 +174,7 @@ export async function mountRouteEditor(
|
||||
curveSpans(line, curveInfo),
|
||||
);
|
||||
};
|
||||
const setStationSnap = (on: boolean): void => {
|
||||
stationSnap = on;
|
||||
stationSnapButton.classList.toggle("is-active", on);
|
||||
stationSnapButton.setAttribute("aria-pressed", String(on));
|
||||
};
|
||||
const setStationSnap = (on: boolean) => paintStationSnap(stationSnapButton, (stationSnap = on));
|
||||
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
|
||||
let history: RouteEditHistory | null = null;
|
||||
let meta: VWorldMeta | null = null;
|
||||
@@ -264,8 +261,8 @@ export async function mountRouteEditor(
|
||||
const slope = createSlopeLabels({ projectId, isClosed: () => closed, onChange: () => draw() });
|
||||
/** 1m 간격 등고선 켬끔 — 기본 꺼짐(계획서 18 ②). */
|
||||
const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw());
|
||||
stationSnapButton.addEventListener("click", () => {
|
||||
setStationSnap(!stationSnap);
|
||||
const snapStepM = bindStationSnap(chrome, projectId, stationIntervalM, (toggle) => {
|
||||
if (toggle) setStationSnap(!stationSnap);
|
||||
if (stationSnap && history) applyEdit("곡선 가운데(MC)를 일반측점에 맞췄습니다."); // 전체 · 한 걸음
|
||||
draw();
|
||||
});
|
||||
@@ -416,7 +413,7 @@ export async function mountRouteEditor(
|
||||
violationCount: nodeInfo.filter((node) => node.violations.length).length,
|
||||
minRadiusM,
|
||||
fresh: nodeInfo.length === 0,
|
||||
offStation: stationSnap ? fit.offStationCount(plannedLine, curveInfo, stationIntervalM) : 0,
|
||||
offStation: stationSnap ? fit.offStationCount(plannedLine, curveInfo, snapStepM()) : 0,
|
||||
});
|
||||
|
||||
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
|
||||
@@ -450,7 +447,7 @@ export async function mountRouteEditor(
|
||||
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다.
|
||||
* 측점 스냅 켬이면 고친 자리 앞 곡선부터 MC 를 일반측점에(366-3 · 되살림 `fitting=false` 은 안 맞춤). */
|
||||
function applyEdit(message: string, record = true, fitting = true): void {
|
||||
const limits = { minRadiusM, limitRadiusM, limitArcM, intervalM: stationIntervalM };
|
||||
const limits = { minRadiusM, limitRadiusM, limitArcM, intervalM: snapStepM() };
|
||||
const from = fit.fitStart(history?.current?.(), snapshotNow());
|
||||
if (stationSnap && fitting) planned = fit.fitCurveStations(snapshotNow(), limits, from);
|
||||
markEdited();
|
||||
@@ -574,6 +571,7 @@ export async function mountRouteEditor(
|
||||
getMeta: () => meta,
|
||||
// 돌린 지도에서는 손이 민 방향과 그림이 움직일 방향이 다르다 — 거꾸로 돌려 넘긴다.
|
||||
unrotateDelta: rotation.unrotateDelta,
|
||||
rotateWheel: rotation.wheel, // Ctrl+휠 = 5° 돌리기(390-3)
|
||||
draw,
|
||||
});
|
||||
|
||||
|
||||
@@ -110,6 +110,16 @@ export function createRouteEditChrome(
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="station-snap"
|
||||
aria-pressed="false"
|
||||
title="켜면 곡선 가운데(MC)를 가까운 일반측점에 맞춥니다 — 놓을 때 고친 자리부터 교각점을 간격 절반 안에서 밂 · 고정 교각점은 그대로 · 못 맞춘 곡선은 상태줄 「측점 밖 곡선」 · [저장] 때 저장">측점 스냅</button>
|
||||
<label class="b05-routeedit__terrain b05-routeedit__snap-step"
|
||||
title="측점 스냅 간격 — 곡선 가운데(MC)를 이 간격 배수에 맞춥니다(측점 No. = 측점 간격)">간격
|
||||
<select class="ui-select ui-select--sm" data-act="snap-step">
|
||||
<option value="0.1">0.1 m</option>
|
||||
<option value="1">1 m</option>
|
||||
<option value="10">10 m</option>
|
||||
<option value="station">측점 No.</option>
|
||||
</select>
|
||||
</label>
|
||||
<i class="b05-route__view-separator" aria-hidden="true"></i>
|
||||
<label class="b05-routeedit__terrain" hidden
|
||||
title="노선 편집 분석(등고선 · 지반고 · 기울기 · 횡단 미리보기 · 추천)에 쓸 지형 — 확정 지표면 · 종횡단은 안 바뀝니다">분석 지형
|
||||
<select class="ui-select ui-select--sm" data-act="terrain">
|
||||
@@ -124,6 +134,7 @@ export function createRouteEditChrome(
|
||||
<span>선 두 번 클릭 = 노드 추가</span><span>노드 오른쪽 클릭 = 삭제</span>
|
||||
<span>측점 눈금 클릭 = 횡단 미리보기</span><span>Shift+클릭 = 거리·기울기</span>
|
||||
<span>가운데(휠) 버튼 끌기 = 지도 이동</span><span>휠 = 확대</span>
|
||||
<span>Ctrl+휠 = 돌리기(한 칸 5°)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b05-routeedit__measure" hidden>
|
||||
@@ -149,7 +160,7 @@ export function createRouteEditChrome(
|
||||
overlay.querySelector('[data-slot="tools"]')!.replaceWith(...tools);
|
||||
|
||||
// 횡단 미리보기 = 패널 양식(382-1 · 손잡이 · 머리 · 폭 끌기 · 열림 · 폭 기억) · 자리는 지도 위 덮개 그대로 ·
|
||||
// 기억이 없으면 좁은 화면은 접힌 채 시작(지도가 먼저다) · 잰 값 창이 판을 비킴(--b05-side-open).
|
||||
// 기본 접힘(393-1 · 키 -v2 = 옛 열림 기억 한 번 버림) · 손잡이로 연 것만 기억 · 잰 값 창이 판을 비킴(--b05-side-open).
|
||||
const side = document.createElement("div");
|
||||
side.className = "b05-routeedit__side";
|
||||
const setSideOpen = (): void =>
|
||||
@@ -158,9 +169,9 @@ export function createRouteEditChrome(
|
||||
`${(panels.current() ? panels.width() : 0) + SIDE_HANDLE_PX}px`,
|
||||
);
|
||||
const panels = createSidePanels({
|
||||
prefKey: "b05-routeedit-cross",
|
||||
prefKey: "b05-routeedit-cross-v2",
|
||||
panels: [{ id: "cross", title: "횡단", body: side }],
|
||||
defaultOpen: window.innerWidth >= 1500 ? "cross" : null,
|
||||
defaultOpen: null,
|
||||
defaultWidth: 460,
|
||||
follow: false,
|
||||
onChange: setSideOpen,
|
||||
|
||||
@@ -287,7 +287,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
|
||||
shown = null;
|
||||
redraw();
|
||||
const panels = sidePanelsOf.get(params.side);
|
||||
if (panels && panels.current() === null) panels.open("cross");
|
||||
if (panels && panels.current() === null) panels.open("cross", false); // 열되 기억 안 함(393-1)
|
||||
}
|
||||
|
||||
/** 다른 보기(종단 · 3D · 목록 · 횡단 카드) 고름 → 일반측점이면 그 측점 횡단 · 구조물이면 마름모. */
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Curve_Pick.ts
|
||||
* 노선 편집 평면 **고른 곡선 밝힘 · 곡선 없는 교각점 삼각형**(요구 242-12 · 13 · PLAN 391-2).
|
||||
*
|
||||
* 고른 곡선(교각점 고름 · 호 누름 = 같은 `picked`) = 호 굵게 + 교각점→BC · EC 접선 + BC · MC · EC
|
||||
* 세 점(하늘색). `_Render` 가 노드 · 손잡이 뒤 맨 위에서 부름 — 집기(`_Input`)는 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render";
|
||||
import { curveArcVertices, curveMidVertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
|
||||
/** 고름 색 — 측점 법선(`_Hover`)과 같은 하늘색. */
|
||||
const PICK_COLOR = "#0ea5e9";
|
||||
/** 삼각형 외접 반지름 = 노드 반지름 × 이 값 — 원과 넓이가 비슷해 보이게. */
|
||||
const TRIANGLE_SCALE = 1.4;
|
||||
|
||||
/** 곡선 없는 교각점 삼각형(391-2) — 꼭짓점 위 · 원 대신 같은 자리 · 크기. 경로만 그림. */
|
||||
export function traceNodeTriangle(
|
||||
context: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
radius: number,
|
||||
): void {
|
||||
const r = radius * TRIANGLE_SCALE;
|
||||
context.moveTo(x, y - r);
|
||||
context.lineTo(x + r * Math.cos(Math.PI / 6), y + r / 2);
|
||||
context.lineTo(x - r * Math.cos(Math.PI / 6), y + r / 2);
|
||||
context.closePath();
|
||||
}
|
||||
|
||||
/** 고른 곡선 밝힘(391-2) — 곡선을 지운 교각점 · 고름 없음이면 안 그림. */
|
||||
export function drawPickedCurve(
|
||||
context: CanvasRenderingContext2D,
|
||||
scene: RouteEditScene,
|
||||
style: CSSStyleDeclaration,
|
||||
): void {
|
||||
const curve = scene.curveInfo.find((item) => item.node_first === scene.picked);
|
||||
if (!curve || scene.curveOn[curve.node_first] === false) return;
|
||||
const halo = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
|
||||
const path = (points: ReadonlyArray<Vertex>): void => {
|
||||
context.beginPath();
|
||||
points.forEach((vertex, index) => {
|
||||
const [x, y] = scene.toScreen(vertex);
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
};
|
||||
context.save();
|
||||
context.strokeStyle = PICK_COLOR;
|
||||
context.lineWidth = 2;
|
||||
context.setLineDash([6, 4]);
|
||||
path([curve.start, curve.apex, curve.end]);
|
||||
context.stroke();
|
||||
context.setLineDash([]);
|
||||
path(curveArcVertices(curve));
|
||||
context.strokeStyle = halo;
|
||||
context.lineWidth = 8;
|
||||
context.stroke();
|
||||
context.strokeStyle = PICK_COLOR;
|
||||
context.lineWidth = 5;
|
||||
context.stroke();
|
||||
context.fillStyle = PICK_COLOR;
|
||||
context.strokeStyle = halo;
|
||||
context.lineWidth = 2;
|
||||
[curve.start, curveMidVertex(curve), curve.end].forEach((point) => {
|
||||
const [x, y] = scene.toScreen(point);
|
||||
context.beginPath();
|
||||
context.arc(x, y, 5, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
});
|
||||
context.restore();
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
* 노선 편집 평면 **맴돔 밝힘**(요구 241-14 · PLAN 381-3).
|
||||
*
|
||||
* 맴도는 자리는 `_Pointer` 가 `_Input.pickAtScreen`(집기와 같은 셈)으로 적고, 여기서 이길 쪽만
|
||||
* 그린다 — 노드 = 바깥 고리 · 횡단 눈금 = 파란 짧은 직각선. `_Render` 가 노드 그린 뒤 부름.
|
||||
* 그린다 — 노드 = 바깥 고리 · 횡단 눈금 = 고른 측점과 같은 좌우 20 m 법선(`drawStationNormal` ·
|
||||
* 391-1). `_Render` 가 노드 그린 뒤 부름.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pickHover } from "./B05_Profile_UI_RouteEdit_Input";
|
||||
@@ -11,10 +12,52 @@ import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render";
|
||||
import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame";
|
||||
import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
|
||||
/** 횡단 눈금 맴돔 색 — 횡단 반폭 선(`_Render`)과 같은 하늘색. */
|
||||
const STATION_HOVER_COLOR = "#0ea5e9";
|
||||
/** 측점 법선 색 — 노선(주황)·재기(초록)·등고선(연보라)과 겹치지 않는 하늘색. */
|
||||
const STATION_NORMAL_COLOR = "#0ea5e9";
|
||||
/** 법선 좌우 길이(m) — 서버 `SECTION_CROSS_HALF_WIDTH_M` 과 같은 값 · 고정(391-1). */
|
||||
const STATION_NORMAL_HALF_M = 20;
|
||||
|
||||
/** 맴도는 자리 밝힘(381-3) — 누르면 집힐 쪽만: 노드 = 바깥 고리 · 횡단 눈금 = 파란 짧은 직각선. */
|
||||
/** 측점 법선(391-1) — 맴돔 · 고름 한 그림: 측점에서 노선에 직각 좌우 20 m · 끝 점 · 측점 고리.
|
||||
* 방향은 지금 그려진 노선으로 매 프레임 다시 짚음(`stationFrameAt`) — 끄는 동안 바로 따라감. */
|
||||
export function drawStationNormal(
|
||||
context: CanvasRenderingContext2D,
|
||||
scene: RouteEditScene,
|
||||
line: ReadonlyArray<Vertex>,
|
||||
chainageM: number,
|
||||
): void {
|
||||
const frame = stationFrameAt(line, chainageM);
|
||||
if (!frame) return;
|
||||
const [ox, oy] = frame.origin;
|
||||
const [lx, ly] = frame.left;
|
||||
const half = STATION_NORMAL_HALF_M;
|
||||
const [cx, cy] = scene.toScreen(frame.origin);
|
||||
const [ax, ay] = scene.toScreen([ox + lx * half, oy + ly * half]);
|
||||
const [bx, by] = scene.toScreen([ox - lx * half, oy - ly * half]);
|
||||
context.save();
|
||||
context.setLineDash([]);
|
||||
context.strokeStyle = STATION_NORMAL_COLOR;
|
||||
context.fillStyle = STATION_NORMAL_COLOR;
|
||||
context.lineWidth = 2.4;
|
||||
context.beginPath();
|
||||
context.moveTo(ax, ay);
|
||||
context.lineTo(bx, by);
|
||||
context.stroke();
|
||||
for (const [x, y] of [
|
||||
[ax, ay],
|
||||
[bx, by],
|
||||
]) {
|
||||
context.beginPath();
|
||||
context.arc(x, y, 3, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
context.lineWidth = 2;
|
||||
context.beginPath();
|
||||
context.arc(cx, cy, 6, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/** 맴도는 자리 밝힘(381-3) — 누르면 집힐 쪽만: 노드 = 바깥 고리 · 횡단 눈금 = 측점 법선. */
|
||||
export function drawPickHover(
|
||||
context: CanvasRenderingContext2D,
|
||||
scene: RouteEditScene,
|
||||
@@ -41,27 +84,7 @@ export function drawPickHover(
|
||||
context.stroke();
|
||||
}
|
||||
} else {
|
||||
const frame = stationFrameAt(line, hover.chainage);
|
||||
if (frame) {
|
||||
const [ax, ay] = scene.toScreen(frame.origin);
|
||||
const [bx, by] = scene.toScreen([
|
||||
frame.origin[0] + frame.left[0],
|
||||
frame.origin[1] + frame.left[1],
|
||||
]);
|
||||
const length = Math.hypot(bx - ax, by - ay) || 1;
|
||||
const ux = ((bx - ax) / length) * 12;
|
||||
const uy = ((by - ay) / length) * 12;
|
||||
context.strokeStyle = STATION_HOVER_COLOR;
|
||||
context.fillStyle = STATION_HOVER_COLOR;
|
||||
context.lineWidth = 3;
|
||||
context.beginPath();
|
||||
context.moveTo(ax - ux, ay - uy);
|
||||
context.lineTo(ax + ux, ay + uy);
|
||||
context.stroke();
|
||||
context.beginPath();
|
||||
context.arc(ax, ay, 4, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
drawStationNormal(context, scene, line, hover.chainage);
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
* 있어 좌상단 기준으로 셈하면 확대할수록 커서 아래 지점이 밀린다(옛 모달의 버그).
|
||||
* ========================================================================== */
|
||||
|
||||
import { pickPx, wheelZoomsIn } from "../A00_Common/touch_bridge";
|
||||
import { isPinchWheel, pickPx, wheelZoomsIn } from "../A00_Common/touch_bridge";
|
||||
import { isTrackpadPinch } from "../A00_Common/input_prefs";
|
||||
import { computeMaxScale, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { StationLabelBox } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
||||
@@ -37,6 +38,8 @@ export interface RouteEditNavigationParams {
|
||||
unrotateDelta?: (dx: number, dy: number) => [number, number];
|
||||
/** 마우스 왼쪽 버튼으로도 팬 — 집을 노드가 없는 미리 보기 지도용. 편집 창은 가운데 버튼만. */
|
||||
leftPan?: boolean;
|
||||
/** Ctrl+휠 = 돌리기(390-3) — 안 주면 Ctrl+휠도 확대(추천 지도). 손가락 · 트랙패드 벌림은 늘 확대. */
|
||||
rotateWheel?: (sign: 1 | -1) => void;
|
||||
draw: () => void;
|
||||
}
|
||||
|
||||
@@ -50,6 +53,8 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
|
||||
"wheel",
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
if (params.rotateWheel && event.ctrlKey && !isPinchWheel(event) && !isTrackpadPinch(event))
|
||||
return params.rotateWheel(Math.sign(event.deltaY) as 1 | -1); // 벌림 아님 = |deltaY| ≥ 50
|
||||
const view = getView();
|
||||
const maxScale = computeMaxScale(
|
||||
getMeta(),
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
*
|
||||
* [측점] 끔 = 측점 눈금 · 번호 안 그림 · 눈금 집기 안 함(시점 · 종점 이름표는 남음).
|
||||
* [기울기] 끔 = 측점 사이 기울기 라벨 안 그림 → 높이도 안 물음.
|
||||
* 켬끔은 이 페이지 동안만(기본 켬 · 계정 설정 아님) — `_Footprint` 와 같은 꼴.
|
||||
* 켬끔은 이 페이지 동안만(기본 [측점] 켬 · [기울기] 끔(요구 242-17) · 계정 설정 아님) — `_Footprint` 와 같은 꼴.
|
||||
* ========================================================================== */
|
||||
|
||||
import { scheduleRedraw } from "./B05_Profile_UI_RouteEdit_Footprint";
|
||||
|
||||
export type RouteEditMark = "stations" | "slopes";
|
||||
|
||||
const shown: Record<RouteEditMark, boolean> = { stations: true, slopes: true };
|
||||
const shown: Record<RouteEditMark, boolean> = { stations: true, slopes: false };
|
||||
|
||||
export const markShown = (mark: RouteEditMark): boolean => shown[mark];
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ import {
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
||||
import type { EditedCurve, EditedNode, Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import { contourBandRect, setStationLabelBoxes } from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import { drawPickHover } from "./B05_Profile_UI_RouteEdit_Hover";
|
||||
import { drawPickHover, drawStationNormal } from "./B05_Profile_UI_RouteEdit_Hover";
|
||||
import { drawPickedCurve, traceNodeTriangle } from "./B05_Profile_UI_RouteEdit_Curve_Pick";
|
||||
import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross";
|
||||
import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame";
|
||||
import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure";
|
||||
@@ -37,8 +38,8 @@ import { markShown } from "./B05_Profile_UI_RouteEdit_Marks";
|
||||
|
||||
/** 노드 반지름(px) — 요구 241-14 로 4 → 6(고르면 +2). */
|
||||
const NODE_R = 6;
|
||||
/** 고정한 선 · 호 굵기(px) — 노선(2.4)보다 조금 굵게 덧그려 밑의 주황이 비치지 않게. */
|
||||
const LOCKED_WIDTH = 3;
|
||||
/** 고정한 선 · 호 굵기(px) — 노선(2.4)보다 굵게 · 흰 테(`--map-halo`)를 밑에 깔아 도드라지게(391-3). */
|
||||
const LOCKED_WIDTH = 4;
|
||||
/** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07).
|
||||
*
|
||||
* 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나
|
||||
@@ -52,8 +53,6 @@ const CURVE_HANDLE_PX = 5;
|
||||
const OUTWARD_PX = 26;
|
||||
/** 구간 재기 표시 색 — 노선(주황)·등고선(연보라)·고른 등고선(보라)과 겹치지 않는 초록. */
|
||||
const MEASURE_COLOR = "#22c55e";
|
||||
/** 횡단 반폭 선 색 — 노선(주황)·재기(초록)·등고선(연보라)과 겹치지 않는 하늘색. */
|
||||
const CROSS_WIDTH_COLOR = "#0ea5e9";
|
||||
|
||||
/** 노선을 따라간 길이(m) — 원호가 이미 정점으로 펴져 있어 정점 간 거리의 합이 곧 길이다. */
|
||||
export function polylineLengthM(points: ReadonlyArray<Vertex>): number {
|
||||
@@ -245,8 +244,9 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
style.getPropertyValue("--map-route") || "#f97316",
|
||||
2.4,
|
||||
);
|
||||
const lockedColor = style.getPropertyValue("--map-route-locked") || "#db2777";
|
||||
strokeLockedRoute(context, scene, line, lockedColor);
|
||||
const lockedColor = style.getPropertyValue("--map-route-locked") || "#ca8a04";
|
||||
const halo = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
|
||||
strokeLockedRoute(context, scene, line, lockedColor, halo);
|
||||
|
||||
drawCrossWidth(context, scene, line); // 측점 라벨이 위에 오게 먼저 그린다.
|
||||
drawStationMarks(context, scene, line);
|
||||
@@ -270,21 +270,23 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
: scene.locked[index]
|
||||
? lockedColor
|
||||
: style.getPropertyValue("--map-route") || "#f97316";
|
||||
context.beginPath();
|
||||
context.arc(x, y, index === scene.picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
// 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다.
|
||||
if (
|
||||
scene.curveOn.length &&
|
||||
// 곡선 없는 교각점(155° 생략 · 손 지움)은 같은 크기 삼각형 · 가운데 비움(391-2).
|
||||
const noCurve =
|
||||
scene.curveOn.length > 0 &&
|
||||
!scene.curveOn[index] &&
|
||||
index > 0 &&
|
||||
index < scene.planned.length - 1
|
||||
) {
|
||||
index < scene.planned.length - 1;
|
||||
const radius = index === scene.picked ? NODE_R + 2 : NODE_R;
|
||||
context.beginPath();
|
||||
if (noCurve) traceNodeTriangle(context, x, y, radius);
|
||||
else context.arc(x, y, radius, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
if (noCurve) {
|
||||
context.save();
|
||||
context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
|
||||
context.fillStyle = halo;
|
||||
context.beginPath();
|
||||
context.arc(x, y, NODE_R - 2, 0, Math.PI * 2);
|
||||
traceNodeTriangle(context, x, y, NODE_R - 2);
|
||||
context.fill();
|
||||
context.restore();
|
||||
}
|
||||
@@ -314,6 +316,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
});
|
||||
});
|
||||
context.restore();
|
||||
drawPickedCurve(context, scene, style);
|
||||
drawPickHover(context, scene, line, style, NODE_R);
|
||||
context.restore(); // 회전 끝
|
||||
}
|
||||
@@ -392,14 +395,8 @@ function drawMeasureMarks(
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 고른 측점의 **좌·우 법선**(382-2) — 측점에서 노선에 직각, 길이 = 그 측점 발자국 비탈 끝 좌 · 우 ·
|
||||
* 색 = 깎기(절토) · 쌓기(성토) 토큰(`_Footprint` 와 같은 것) · 기록이 없으면 「횡단 반폭」 양쪽 파랑 ·
|
||||
* 측점 자리에 고리(눈금 강조). 구조물 · 관 고름 = 그 자리 마름모(배수유역도 `markStation` 꼴)만.
|
||||
*
|
||||
* 자리·방향은 **지금 그려진 노선**으로 매 프레임 다시 짚는다(`stationFrameAt` — 서버
|
||||
* `generate_sections` 짝) — 노드를 끄는 동안 선이 네트워크 없이 바로 따라간다.
|
||||
*/
|
||||
/** 고른 측점 — 맴돔과 같은 좌우 20 m 법선(391-1 · `drawStationNormal`) · 구조물 · 관 고름 =
|
||||
* 그 자리 마름모(배수유역도 `markStation` 꼴)만. 절성토 색은 발자국 선이 보여 줌. */
|
||||
function drawCrossWidth(
|
||||
context: CanvasRenderingContext2D,
|
||||
scene: RouteEditScene,
|
||||
@@ -407,53 +404,23 @@ function drawCrossWidth(
|
||||
): void {
|
||||
const watch = scene.crossWidth;
|
||||
if (!watch) return;
|
||||
if (!watch.structure) {
|
||||
drawStationNormal(context, scene, line, watch.chainageM);
|
||||
return;
|
||||
}
|
||||
const frame = stationFrameAt(line, watch.chainageM);
|
||||
if (!frame) return;
|
||||
const [ox, oy] = frame.origin;
|
||||
const [lx, ly] = frame.left;
|
||||
const [cx, cy] = scene.toScreen(frame.origin);
|
||||
context.save();
|
||||
context.setLineDash([]);
|
||||
if (watch.structure) {
|
||||
context.translate(cx, cy);
|
||||
context.rotate(Math.PI / 4);
|
||||
context.beginPath();
|
||||
context.rect(-7, -7, 14, 14);
|
||||
context.fillStyle = "rgba(255, 196, 0, 0.35)";
|
||||
context.fill();
|
||||
context.lineWidth = 2.5;
|
||||
context.strokeStyle = "#ff9800";
|
||||
context.stroke();
|
||||
context.restore();
|
||||
return;
|
||||
}
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const tone = (cut: boolean | undefined): string =>
|
||||
cut === undefined
|
||||
? CROSS_WIDTH_COLOR
|
||||
: style.getPropertyValue(cut ? "--b05-footprint-cut" : "--b05-footprint-fill").trim() ||
|
||||
(cut ? "#dc2626" : "#2563eb");
|
||||
const ends: Array<[Vertex, string]> = [
|
||||
[[ox + lx * watch.leftM, oy + ly * watch.leftM], tone(watch.leftCut)],
|
||||
[[ox - lx * watch.rightM, oy - ly * watch.rightM], tone(watch.rightCut)],
|
||||
];
|
||||
context.lineWidth = 2.4;
|
||||
ends.forEach(([end, color]) => {
|
||||
const [x, y] = scene.toScreen(end);
|
||||
context.strokeStyle = color;
|
||||
context.fillStyle = color;
|
||||
context.beginPath();
|
||||
context.moveTo(cx, cy);
|
||||
context.lineTo(x, y);
|
||||
context.stroke();
|
||||
context.beginPath();
|
||||
context.arc(x, y, 3, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
});
|
||||
context.strokeStyle = CROSS_WIDTH_COLOR;
|
||||
context.lineWidth = 2;
|
||||
context.translate(cx, cy);
|
||||
context.rotate(Math.PI / 4);
|
||||
context.beginPath();
|
||||
context.arc(cx, cy, 6, 0, Math.PI * 2);
|
||||
context.rect(-7, -7, 14, 14);
|
||||
context.fillStyle = "rgba(255, 196, 0, 0.35)";
|
||||
context.fill();
|
||||
context.lineWidth = 2.5;
|
||||
context.strokeStyle = "#ff9800";
|
||||
context.stroke();
|
||||
context.restore();
|
||||
}
|
||||
@@ -597,6 +564,7 @@ function strokeLockedRoute(
|
||||
scene: RouteEditScene,
|
||||
line: ReadonlyArray<Vertex>,
|
||||
color: string,
|
||||
halo: string,
|
||||
): void {
|
||||
const { planned, locked } = scene;
|
||||
if (!locked.some(Boolean)) return;
|
||||
@@ -616,9 +584,10 @@ function strokeLockedRoute(
|
||||
curveAt.forEach((curve, index) => {
|
||||
if (arcLocked(locked, index)) pieces.push(sliceBetween(line, curve.start, curve.end));
|
||||
});
|
||||
pieces.forEach((piece) =>
|
||||
strokePolyline(context, scene.toScreen, piece, [], color, LOCKED_WIDTH),
|
||||
);
|
||||
pieces.forEach((piece) => {
|
||||
strokePolyline(context, scene.toScreen, piece, [], halo, LOCKED_WIDTH + 3);
|
||||
strokePolyline(context, scene.toScreen, piece, [], color, LOCKED_WIDTH);
|
||||
});
|
||||
}
|
||||
|
||||
/** 노선에서 두 점(정점 위)에 가장 가까운 정점 사이를 잘라 낸다. */
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
/** 단추 한 번에 도는 각(도) — 15°씩 스물넷이면 한 바퀴. 90°씩이면 맞출 자리가 넷뿐이고,
|
||||
* 5°씩이면 한 바퀴에 일흔두 번이라 성가시다. */
|
||||
const ROTATE_STEP_DEG = 15;
|
||||
/** Ctrl+휠 한 칸에 도는 각(도 · 390-3 요구 242-16) — 단추보다 잘게. */
|
||||
const WHEEL_STEP_DEG = 5;
|
||||
|
||||
/** **글자를 눈높이로 세울지**(계획서 0-9 ㉚, 2026-09-12 사용자 지시).
|
||||
*
|
||||
@@ -43,6 +45,8 @@ export interface MapRotation {
|
||||
uprightRad: () => number;
|
||||
/** 기억해 둔 각으로 되돌린다(계획서 18 ③). 다시 그리기는 부르는 쪽 몫. */
|
||||
set: (radians: number) => void;
|
||||
/** Ctrl+휠 한 칸 — `sign` 1 = 시계 · -1 = 반시계로 `WHEEL_STEP_DEG` 돌고 알림. */
|
||||
wheel: (sign: 1 | -1) => void;
|
||||
}
|
||||
|
||||
export function createMapRotation(params: MapRotationParams): MapRotation {
|
||||
@@ -60,14 +64,18 @@ export function createMapRotation(params: MapRotationParams): MapRotation {
|
||||
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
|
||||
};
|
||||
|
||||
const turn = (deg: number): void => {
|
||||
radians += (deg * Math.PI) / 180;
|
||||
params.onChange();
|
||||
};
|
||||
|
||||
for (const [act, sign] of [
|
||||
["rotate-ccw", -1],
|
||||
["rotate-cw", 1],
|
||||
] as const) {
|
||||
params.overlay.querySelector(`[data-act="${act}"]`)?.addEventListener("click", () => {
|
||||
radians += (sign * ROTATE_STEP_DEG * Math.PI) / 180;
|
||||
params.onChange();
|
||||
});
|
||||
params.overlay
|
||||
.querySelector(`[data-act="${act}"]`)
|
||||
?.addEventListener("click", () => turn(sign * ROTATE_STEP_DEG));
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -75,6 +83,7 @@ export function createMapRotation(params: MapRotationParams): MapRotation {
|
||||
unrotate: (px, py) => spin(px, py, -radians),
|
||||
rerotate: (px, py) => spin(px, py, radians),
|
||||
uprightRad: () => (UPRIGHT_LABELS ? -radians : 0),
|
||||
wheel: (sign) => turn(sign * WHEEL_STEP_DEG),
|
||||
set: (next) => {
|
||||
radians = Number.isFinite(next) ? next : 0;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_SnapStep.ts
|
||||
* 노선 편집 [측점 스냅] 단추 · 스냅 간격 고르기(390-2 · 요구 242-11).
|
||||
*
|
||||
* 간격 = 0.1 m · 1 m · 10 m · 측점 No.(편집 창 측점 간격 · 기본) — 곡선 MC 를 그 간격 배수에 맞춤.
|
||||
* 고른 값은 프로젝트별 화면 기억(pref `routeedit-snap-step`) · [저장] 대상 아님(서버 · 추천은 측점 No. 그대로).
|
||||
* 스냅 끔이면 고르기는 흐림(CSS) · 고를 수는 있음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import type { RouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome";
|
||||
|
||||
const STEPS = ["0.1", "1", "10", "station"];
|
||||
|
||||
/** [측점 스냅] 단추 켬끔 모양(`is-active` · `aria-pressed`). */
|
||||
export function paintStationSnap(button: HTMLButtonElement, on: boolean): void {
|
||||
button.classList.toggle("is-active", on);
|
||||
button.setAttribute("aria-pressed", String(on));
|
||||
}
|
||||
|
||||
/**
|
||||
* 단추 · 고르기 배선 — `onChange(true)` = 단추 누름(켬끔 바꿈) · `onChange(false)` = 간격 바꿈.
|
||||
* 돌려주는 함수 = 지금 스냅 간격(m).
|
||||
*/
|
||||
export function bindStationSnap(
|
||||
chrome: Pick<RouteEditChrome, "overlay" | "stationSnapButton">,
|
||||
projectId: string,
|
||||
stationIntervalM: number,
|
||||
onChange: (toggle: boolean) => void,
|
||||
): () => number {
|
||||
const select = chrome.overlay.querySelector<HTMLSelectElement>('[data-act="snap-step"]')!;
|
||||
const saved = readState<string>("routeedit-snap-step", projectId);
|
||||
select.value = saved && STEPS.includes(saved) ? saved : "station";
|
||||
select.addEventListener("change", () => {
|
||||
writeState("routeedit-snap-step", select.value, projectId);
|
||||
onChange(false);
|
||||
});
|
||||
chrome.stationSnapButton.addEventListener("click", () => onChange(true));
|
||||
return () => (select.value === "station" ? stationIntervalM : Number(select.value));
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export function buildStructuresForm(options: {
|
||||
// b05-structure-section: 이 섹션 안의 입력·버튼 높이를 한 값(--b05-control-h)으로
|
||||
// 묶는 스코프(2026-08-29 사용자 지시 — 조작 컨트롤 높이 통일).
|
||||
root.className =
|
||||
"b05-route__panel-section b05-structure-section ui-collapsible ui-sidebar-section";
|
||||
"b05-route__panel-section b05-structure-section ui-collapsible ui-sidebar-section is-collapsed";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "ui-collapsible__title";
|
||||
heading.textContent = "구조물 배치";
|
||||
|
||||
@@ -43,73 +43,26 @@
|
||||
}
|
||||
|
||||
/* 본문 칸을 세로로 쌓아 그래프 스크롤러 아래에 손잡이가 붙게 한다.
|
||||
테이블 겹침 판의 절대배치 기준이기도 하다. */
|
||||
오버레이의 절대배치 기준이기도 하다. */
|
||||
.b05-route-profile__body-wrap {
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 종단 열 = 종단 칸 + 그 아래 유토곡선 판(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로 조절한다. */
|
||||
/* 오버레이 서브패널 — 패널 바닥(접기 손잡이 위)에 붙어 종단도·테이블 위를 덮는다
|
||||
(2026-08-04 사용자 확정). 높이는 위 경계 리사이저가 --b05-masshaul-height로 조절한다. */
|
||||
.b05-profile__masshaul-overlay {
|
||||
position: relative;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0; /* 손잡이는 표준 삼각형(중앙, z-index 6)이라 자리를 차지하지 않는다 */
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
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);
|
||||
}
|
||||
|
||||
/* 머리줄 — 펼침에선 곡선 위 여백(padTop)에 떠 자리를 안 먹고, 접으면 판에 머리줄만 남는다. */
|
||||
.b05-profile__masshaul-head {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 0;
|
||||
left: var(--spacing-8);
|
||||
/* 고정 Y축(z5 · 불투명) 위 — 아래면 [창 분할]이 축 띠에 가려짐 */
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-head > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 접음 = 판 높이 0 · 손잡이만 판 위 경계에 매달림(배수유역 접힘 꼴 · 367-2). */
|
||||
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.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 {
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
}
|
||||
|
||||
/* [창 분할]은 펼친 때만(367-2) — 새 창으로 뗀 [창 결합]은 그대로. */
|
||||
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) .b05-subpanel__popout {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 종단 패널을 접으면 판도 함께 숨는다(배수유역도와 같음). */
|
||||
.b05-route-profile.is-collapsed .b05-profile__masshaul-overlay {
|
||||
display: none;
|
||||
box-shadow: 0 -4px 12px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
/* 공용 리사이저는 절대배치라 오버레이 위 경계에 그대로 앉는다. */
|
||||
@@ -143,26 +96,18 @@
|
||||
}
|
||||
|
||||
/* 범례 오버레이 — 유토곡선 우상단에 절대배치(B06과 같은 문법). 요약 막대 아래에 앉힌다.
|
||||
공용 범례(.b06-masshaul__legend)가 이미 absolute + right 배치라 top만 이 래퍼가 정한다.
|
||||
왼쪽 끝 = 손잡이(중앙 +44 · 폭 72) 오른쪽 — 좁으면 범례가 줄을 바꿔 손잡이를 피함(367-6). */
|
||||
공용 범례(.b06-masshaul__legend)가 이미 absolute + right 배치라 top만 이 래퍼가 정한다. */
|
||||
.b05-profile__masshaul-legend {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 0;
|
||||
left: calc(50% + 92px);
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-legend .b06-masshaul__legend {
|
||||
left: 0;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-legend .b06-masshaul__legend > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -195,12 +140,13 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 접기 손잡이 — 다른 패널들과 **같은 표준 삼각형 손잡이**를 그대로 쓴다
|
||||
(2026-08-04 사용자 지시). 판 머리줄에 앉아 접어도 닿는다(354-1). 테이블 손잡이와
|
||||
겹치지 않게 중앙에서 우측으로 비켜 세운다(2026-08-05 사용자 지시 — 좌: 테이블, 우: 유토곡선). */
|
||||
/* 2차 슬라이드 손잡이 — 다른 패널들과 **같은 표준 삼각형 손잡이**를 그대로 쓴다
|
||||
(2026-08-04 사용자 지시). 패널 본문 바닥에 붙고, 오버레이(z-index 5) 위에 뜬다.
|
||||
접혔을 때 테이블 손잡이와 **일렬**로 놓이도록 중앙에서 우측으로 비켜 세운다
|
||||
(2026-08-05 사용자 지시 — 좌: 테이블, 우: 유토곡선). */
|
||||
.b05-profile__masshaul-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: calc(50% + 44px);
|
||||
z-index: 6;
|
||||
transform: translateX(-50%);
|
||||
@@ -215,7 +161,8 @@
|
||||
}
|
||||
|
||||
/* ── 테이블 오버레이 서브패널 (2026-08-05 사용자 지시) ──────────────────────
|
||||
종단 칸 바닥에 붙어 종단도 위를 덮는다. 유토곡선은 그 아래 자기 판(354-1). */
|
||||
유토곡선과 같은 뼈대. 유토곡선이 열려 있으면 그 위에 쌓인다(bottom은 JS가 조절).
|
||||
위→아래 순서: 종단도 → 테이블 → 유토곡선. */
|
||||
.b05-profile__table-overlay {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
|
||||
@@ -321,6 +321,15 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 분석 지형이 숨으면 앞 구분선도(390-1) · 스냅 끔이면 간격 고르기 흐림(390-2). */
|
||||
.b05-route__view-separator:has(+ .b05-routeedit__terrain[hidden]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-act="station-snap"][aria-pressed="false"] + .b05-routeedit__snap-step {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창(뜬 종단 판이 덮으면 가려진 채 · 349-0). 닫으면 잰 것이 지워진다. */
|
||||
.b05-routeedit__measure {
|
||||
position: absolute;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 밀어 MC(BC · EC 누가거리 가운데)를 가까운 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠.
|
||||
* · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로.
|
||||
* · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음.
|
||||
* · 간격 = 스냅 간격(390-2 · 0.1 · 1 · 10 m · 측점 No.) · 허용 오차도 간격 따라(`mcTolM`).
|
||||
* 파이썬 짝 아님 — [저장] 때 서버가 저장된 IP 로 선을 다시 짬(`buildEditedPolyline` 짝)이라 MC 그대로.
|
||||
* 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `B05_Profile_UI_RouteEdit`.
|
||||
* ========================================================================== */
|
||||
@@ -29,6 +30,9 @@ import { curveSpans } from "./B05_Profile_UI_RouteEdit_Frame";
|
||||
/** MC 가 측점에서 이만큼 안이면 「위」(m) — 파이썬 `MC_TOL_M` 과 같은 값. */
|
||||
export const MC_TOL_M = 0.05;
|
||||
|
||||
/** 간격별 허용 오차 — 0.1 m 간격도 실제로 맞게 간격의 1/20 이하(390-2 · 1 m 이상은 `MC_TOL_M`). */
|
||||
export const mcTolM = (intervalM: number): number => Math.min(MC_TOL_M, intervalM / 20);
|
||||
|
||||
/** 선을 짜는 값 — 편집 창이 쥔 하한 · 간격. */
|
||||
export interface FitLimits {
|
||||
minRadiusM: number;
|
||||
@@ -66,7 +70,7 @@ export function offStationCount(
|
||||
for (const [start, end] of spans) {
|
||||
const mc = (start + end) / 2;
|
||||
const n = Math.round(mc / intervalM);
|
||||
if (n >= 1 && Math.abs(mc - n * intervalM) <= MC_TOL_M) hits.add(n);
|
||||
if (n >= 1 && Math.abs(mc - n * intervalM) <= mcTolM(intervalM)) hits.add(n);
|
||||
}
|
||||
return spans.length - hits.size;
|
||||
}
|
||||
@@ -122,7 +126,7 @@ function solve(
|
||||
const tried = at(d1);
|
||||
if (!tried || d1 === d0) return null;
|
||||
const f1 = tried.mc - target;
|
||||
if (Math.abs(f1) <= MC_TOL_M / 5) return tried;
|
||||
if (Math.abs(f1) <= mcTolM(limits.intervalM) / 5) return tried;
|
||||
const slope = (f1 - f0) / (d1 - d0);
|
||||
if (Math.abs(slope) < 0.2) return null;
|
||||
d0 = d1;
|
||||
@@ -149,7 +153,7 @@ export function fitCurveStations(
|
||||
const mc = mcOf(built, k);
|
||||
if (mc === null) continue;
|
||||
const near = Math.max(lastStation + 1, Math.round(mc / interval));
|
||||
const onStation = Math.abs(mc - near * interval) <= MC_TOL_M;
|
||||
const onStation = Math.abs(mc - near * interval) <= mcTolM(interval);
|
||||
const fitted =
|
||||
onStation || k < from || cols.apexLock[k] || Math.abs(near * interval - mc) > interval / 2
|
||||
? null
|
||||
@@ -162,7 +166,7 @@ export function fitCurveStations(
|
||||
) &&
|
||||
(previous < 0 ||
|
||||
Math.abs((mcOf(fitted.built, previous) ?? Infinity) - (mcOf(built, previous) ?? 0)) <=
|
||||
MC_TOL_M / 5);
|
||||
mcTolM(interval) / 5);
|
||||
if (fitted && keep) ({ planned, built } = fitted);
|
||||
if (onStation || keep) lastStation = near;
|
||||
previous = k;
|
||||
|
||||
@@ -196,6 +196,23 @@ export function curveArcVertices(curve: EditedCurve): Vertex[] {
|
||||
return [curve.start, ...arcPoints(center, curve.start, curve.end, sweep < 0), curve.end];
|
||||
}
|
||||
|
||||
/** 곡선 가운데 MC — 교각점에서 이등분선 따라 외할(R/sin(I/2) − R). 화면 표시 전용 · 짝 아님(391-2). */
|
||||
export function curveMidVertex(curve: EditedCurve): Vertex {
|
||||
const toStart = unit(curve.apex, curve.start);
|
||||
const toEnd = unit(curve.apex, curve.end);
|
||||
const bisector: Vertex = [toStart[0] + toEnd[0], toStart[1] + toEnd[1]];
|
||||
const bisectorLength = Math.hypot(bisector[0], bisector[1]);
|
||||
const half = Math.sin((curve.inner_angle_deg * RAD_PER_DEG) / 2);
|
||||
if (bisectorLength <= 1e-9 || !(half > 1e-9)) {
|
||||
return [(curve.start[0] + curve.end[0]) / 2, (curve.start[1] + curve.end[1]) / 2];
|
||||
}
|
||||
const external = curve.radius_m / half - curve.radius_m;
|
||||
return [
|
||||
curve.apex[0] + (bisector[0] / bisectorLength) * external,
|
||||
curve.apex[1] + (bisector[1] / bisectorLength) * external,
|
||||
];
|
||||
}
|
||||
|
||||
/** 노드 하나의 요약 — 화면이 붉은 점·내각·R 을 그리는 재료(서버 `RouteNode` 와 같은 꼴). */
|
||||
export interface EditedNode {
|
||||
inner_angle_deg: number | null;
|
||||
|
||||
@@ -19,7 +19,7 @@ function shortLabel(label: string, kindName: string): string {
|
||||
export function buildDevelopmentSection(
|
||||
items: DesignDrawingItem[],
|
||||
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
|
||||
collapsed = false,
|
||||
collapsed = true,
|
||||
): HTMLElement {
|
||||
const section = createPanelCard(`전개도 ${items.length}`, collapsed);
|
||||
section.classList.add("b07-drawing-group");
|
||||
|
||||
@@ -44,9 +44,6 @@ export const DRAWING_GROUPS: readonly {
|
||||
{ label: "전개도", kind: "development" },
|
||||
];
|
||||
|
||||
/** 311 기본 접힘 묶음 — 그 밖 묶음(토적도 등)은 기본 펼침. */
|
||||
const FOLDED_KINDS = new Set(["plan", "longitudinal", "cross"]);
|
||||
|
||||
/** 311 지금 목록의 묶음 접힘(kind → 접힘) — 화면을 다시 세울 때 이어 받음 · B07 목록이 없으면 빈 것. */
|
||||
export function foldedGroups(root: ParentNode): Map<string, boolean> {
|
||||
return new Map(
|
||||
@@ -69,7 +66,7 @@ export function buildDrawingSidePanel(
|
||||
/** 311 다시 세울 때 옛 접힘(`foldedGroups`) — 없는 묶음은 기본값. */
|
||||
folded = new Map<string, boolean>(),
|
||||
): HTMLDivElement {
|
||||
const isFolded = (kind: string): boolean => folded.get(kind) ?? FOLDED_KINDS.has(kind);
|
||||
const isFolded = (kind: string): boolean => folded.get(kind) ?? true; // 389-3 모든 묶음 기본 닫힘
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b07-drawing-list";
|
||||
const heading = document.createElement("div");
|
||||
|
||||
@@ -257,7 +257,7 @@ export function buildPlanSection(
|
||||
items: DesignDrawingItem[],
|
||||
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
|
||||
onManage?: () => void,
|
||||
collapsed = false,
|
||||
collapsed = true,
|
||||
): HTMLElement {
|
||||
// 컨테이너 = 좌측 패널 공용 카드(269)
|
||||
const section = createPanelCard(`평면도 ${items.length}`, collapsed);
|
||||
|
||||
@@ -133,13 +133,12 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
});
|
||||
return sec;
|
||||
};
|
||||
// 구조물 집계 양식 — 층 단추 · 나무 · 처음부터 펼침(집계표가 열려 있음) ·
|
||||
// 구조물 집계 양식 — 층 단추 · 나무 · 처음엔 닫힘(집계표는 가운데에 그대로 열려 있음) ·
|
||||
// 같은 이름 줄 단추 없음(제목이 그 이름 · 29-6)
|
||||
const summary = section(
|
||||
L("M02_KindTable"),
|
||||
el("div", { className: "m02-side__items ui-sidebar-list", children: opt.tree ?? [] }),
|
||||
);
|
||||
summary.classList.remove("is-collapsed");
|
||||
openOnExpand(summary, async () => {
|
||||
if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* 224-1b 유토 판 높이 나눔 · 방향키 판정 — 진짜 높이 나눔(`createHeightCascade`)과 방향키 판정
|
||||
/* 224-1b 유토 판 「붙어 있음」 판정 — 진짜 높이 나눔(`createHeightCascade`)과 방향키 판정
|
||||
* (`arrowsBelongToProfile` 본문)을 가짜 DOM 위에서 돌려 JSON 으로 냄.
|
||||
* · 유토곡선 = 종단 열 아래 자기 판(354-1) — 본문 높이에서 이미 빠져 나눔에 안 낌(입력에도 없음)
|
||||
* · 열림 && 뿌리 안 = 자리 차지 · 열림 && 새 창(뿌리 밖) = 자리 0 · 인라인 높이 안 씀
|
||||
* · 방향키 = 마지막 누름이 종단 뿌리 · 뗀 유토 · 뗀 배수 판 안이면 종단도 몫 */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
@@ -44,27 +44,33 @@ new Function(
|
||||
)(
|
||||
heightsExports,
|
||||
(id) =>
|
||||
id.includes("TableOverlay")
|
||||
? { TABLE_OVERLAY_MIN_HEIGHT: 120 }
|
||||
: id.includes("Structures_Marks")
|
||||
? { STRUCTURE_LANE_HEIGHT_PX: 40 }
|
||||
: { frameOf: () => 0 },
|
||||
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 },
|
||||
{ innerHeight: 900 },
|
||||
);
|
||||
|
||||
function run(tableDesired) {
|
||||
function run(docked) {
|
||||
const root = el("b05-route-profile");
|
||||
root.offsetHeight = 500;
|
||||
const body = root.append(el());
|
||||
body.clientHeight = 400;
|
||||
const overlay = el("b05-profile__table-overlay");
|
||||
const overlay = el("b05-profile__masshaul-overlay");
|
||||
if (docked) body.append(overlay);
|
||||
else el("popup-body").append(overlay); // 새 창 body
|
||||
const cascade = heightsExports.createHeightCascade({
|
||||
root,
|
||||
body,
|
||||
massHaul: { overlay, desiredHeight: () => 300, syncHandle() {} },
|
||||
tableOverlay: {
|
||||
overlay,
|
||||
isOpen: () => tableDesired > 0,
|
||||
desiredHeight: () => tableDesired,
|
||||
overlay: el(),
|
||||
isOpen: () => false,
|
||||
desiredHeight: () => 0,
|
||||
setBottomOffset() {},
|
||||
},
|
||||
isMainDragging: () => false,
|
||||
isSubDragging: () => false,
|
||||
@@ -93,9 +99,8 @@ const card = el("b06-cross-card");
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
tableClosed: run(0),
|
||||
tableFits: run(200),
|
||||
tableShrinks: run(300),
|
||||
docked: run(true),
|
||||
popped: run(false),
|
||||
arrows: {
|
||||
profile: arrows(profileChild),
|
||||
mass: arrows(massChild),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* 224-1c 유토곡선 · 배수유역 [창 분할] — 진짜 `attachSubPanelPopouts` · `attachProfilePopout`
|
||||
* (design_profile_popout.ts)를 가짜 DOM · 가짜 창 위에서 돌려 JSON 으로 냄.
|
||||
* · 단추 자리 · 글 · 클래스 · 툴팁 · 창 이름 셋 다름
|
||||
* · 단추 = 두 판 머리줄 맨 앞(유토는 접어도 남는 머리줄 · 354-1)
|
||||
* · 뗌 = 새 창 body · 배수 rehome · 붙임(단추 · 새 창 닫힘 · dispose) = 제자리(종단 열 끝 · content 끝)
|
||||
* · 뗌 = 새 창 body · 유토 손잡이 숨김 · 인라인 높이 비움 · 배수 rehome
|
||||
* · 붙임(단추 · 새 창 닫힘 · dispose) = 제자리(유토 손잡이 앞 · content 끝)
|
||||
* · 종단 새 창 안의 단추 = 그 창에서 엶 */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
@@ -122,25 +122,21 @@ new Function("exports", "require", "window", "document", js)(
|
||||
mainDoc,
|
||||
);
|
||||
|
||||
// ---- 종단 뿌리 = [content = [열 = [종단 칸, 유토 판 = [머리줄 = [손잡이]]], 배수 판 = [머리줄 = [제목]]]] ----
|
||||
// ---- 종단 뿌리 = [content = [bodyWrap = [유토 판, 유토 손잡이], 배수 판 = [머리줄 = [제목]]]] ----
|
||||
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 column = el("div", "b05-route-profile__column");
|
||||
const bodyWrap = el("div", "b05-route-profile__body-wrap");
|
||||
const bodyWrap = el("div");
|
||||
const massOverlay = el("div", "b05-profile__masshaul-overlay");
|
||||
const massHead = el("div", "b05-profile__masshaul-head");
|
||||
const massHandle = el("div", "b05-profile__masshaul-handle");
|
||||
massHead.append(massHandle);
|
||||
massOverlay.append(el("div", "ui-resizer"), massHead);
|
||||
column.append(bodyWrap, massOverlay);
|
||||
bodyWrap.append(massOverlay, massHandle);
|
||||
const drainageRoot = el("aside", "b05-drainage");
|
||||
const header = el("div", "b05-drainage__header");
|
||||
header.append(el("h3"));
|
||||
drainageRoot.append(header);
|
||||
content.append(column, drainageRoot);
|
||||
content.append(bodyWrap, drainageRoot);
|
||||
root.append(content);
|
||||
let rehomes = 0;
|
||||
let disposed = 0;
|
||||
@@ -154,18 +150,20 @@ const profileButton = el("button");
|
||||
mod.attachProfilePopout(profile, slot, profileButton);
|
||||
mod.attachSubPanelPopouts(profile);
|
||||
|
||||
const massButton = massHead.children[0];
|
||||
const massButton = massOverlay.children.find((c) => c.tagName === "BUTTON");
|
||||
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,
|
||||
};
|
||||
@@ -177,10 +175,11 @@ out.drainOpened = {
|
||||
rehomes,
|
||||
text: drainButton.textContent,
|
||||
};
|
||||
// 유토 새 창 닫힘 → 제자리(종단 열 끝)
|
||||
// 유토 새 창 닫힘 → 제자리(손잡이 바로 앞)
|
||||
massPopup.fire("pagehide");
|
||||
out.massClosed = {
|
||||
order: column.children.map((c) => c.className),
|
||||
order: bodyWrap.children.map((c) => c.className),
|
||||
handleHidden: massHandle.hidden,
|
||||
isPopout: massOverlay.classList.contains("is-popout"),
|
||||
text: massButton.textContent,
|
||||
};
|
||||
@@ -206,8 +205,10 @@ profile.dispose();
|
||||
out.disposed = {
|
||||
closed: [profilePopup.closed, massPopup2.closed, drainPopup2.closed],
|
||||
profileInSlot: root.parent === slot,
|
||||
massInColumnEnd: column.children[column.children.length - 1] === massOverlay,
|
||||
massBeforeHandle:
|
||||
bodyWrap.children.indexOf(massOverlay) === bodyWrap.children.indexOf(massHandle) - 1,
|
||||
drainInContent: drainageRoot.parent === content,
|
||||
handleHidden: massHandle.hidden,
|
||||
original: disposed,
|
||||
};
|
||||
out.opened = opened;
|
||||
|
||||
@@ -56,7 +56,7 @@ function columns(planned, lock = []) {
|
||||
};
|
||||
}
|
||||
|
||||
function measure(planned) {
|
||||
function measure(planned, interval = INTERVAL) {
|
||||
const built = curve.buildEditedPolyline(
|
||||
planned,
|
||||
planned.map(() => true),
|
||||
@@ -74,9 +74,9 @@ function measure(planned) {
|
||||
return {
|
||||
curves: built.curves.length,
|
||||
mcs,
|
||||
residues: mcs.map((mc) => Math.abs(mc - Math.round(mc / INTERVAL) * INTERVAL)),
|
||||
residues: mcs.map((mc) => Math.abs(mc - Math.round(mc / interval) * interval)),
|
||||
innerStations: total.filter((s) => s > 0 && s < length).length,
|
||||
off: fit.offStationCount(built.vertices, built.curves, INTERVAL),
|
||||
off: fit.offStationCount(built.vertices, built.curves, interval),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,6 +96,15 @@ console.log(
|
||||
fromLater: { ...measure(fromLater), moved: moved(ROUTE, fromLater) },
|
||||
everyLocked: { ...measure(everyLocked), moved: moved(ROUTE, everyLocked) },
|
||||
again: moved(all, fit.fitCurveStations(columns(all), LIMITS, 1)),
|
||||
// 390-2 스냅 간격 — 0.1 · 1 · 10 m 배수 · 측점 밖 개수도 같은 간격
|
||||
steps: Object.fromEntries(
|
||||
[0.1, 1, 10].map((step) => {
|
||||
const fitted = fit.fitCurveStations(columns(ROUTE), { ...LIMITS, intervalM: step }, 1);
|
||||
const offBefore = measure(ROUTE, step).off;
|
||||
return [step, { offBefore, ...measure(fitted, step), moved: moved(ROUTE, fitted) }];
|
||||
}),
|
||||
),
|
||||
tol: [0.1, 1, 10, 20].map((step) => fit.mcTolM(step)),
|
||||
noInterval: moved(ROUTE, fit.fitCurveStations(columns(ROUTE), { ...LIMITS, intervalM: 0 }, 1)),
|
||||
start: {
|
||||
moved4: fit.fitStart(
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/* 391 B05 평면 그림 단위검증 헬퍼 — 측점 법선(`_Hover.drawStationNormal`) · 고른 곡선 밝힘 ·
|
||||
* 삼각형(`_Curve_Pick`) · MC 점(`logic/_Curve.curveMidVertex`)을 트랜스파일해 가짜 캔버스로 돌린다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
const B05 = path.join(__dirname, "..", "..", "B05_Profile");
|
||||
|
||||
const STUBS = { "./B05_Profile_UI_RouteEdit_Input": { pickHover: () => null } };
|
||||
const cache = new Map();
|
||||
function loadTs(full) {
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
/** 그린 것만 적는 가짜 캔버스 — 선 = moveTo/lineTo 점 · 점 = arc 가운데 · 획 색. */
|
||||
function fakeContext() {
|
||||
const log = { lines: [], dots: [], strokes: [] };
|
||||
let path = [];
|
||||
const ctx = {
|
||||
save() {},
|
||||
restore() {},
|
||||
setLineDash() {},
|
||||
beginPath: () => (path = []),
|
||||
moveTo: (x, y) => path.push([x, y]),
|
||||
lineTo: (x, y) => path.push([x, y]),
|
||||
closePath() {},
|
||||
arc: (x, y, r) => log.dots.push([x, y, r]),
|
||||
stroke: () => {
|
||||
log.strokes.push(ctx.strokeStyle);
|
||||
if (path.length) log.lines.push({ color: ctx.strokeStyle, width: ctx.lineWidth, path });
|
||||
},
|
||||
fill() {},
|
||||
};
|
||||
return { ctx, log, path: () => path };
|
||||
}
|
||||
|
||||
const round = (v) => Math.round(v * 1000) / 1000;
|
||||
const hover = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_Hover.ts"));
|
||||
const pick = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_Curve_Pick.ts"));
|
||||
const curve = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Curve.ts"));
|
||||
const style = { getPropertyValue: () => "" };
|
||||
const out = {};
|
||||
|
||||
// 측점 법선 — 동서 노선 50 m 자리 · 화면 = 2 px/m
|
||||
const scene = { toScreen: ([x, y]) => [x * 2, y * 2] };
|
||||
const normal = fakeContext();
|
||||
hover.drawStationNormal(
|
||||
normal.ctx,
|
||||
scene,
|
||||
[
|
||||
[0, 0],
|
||||
[100, 0],
|
||||
],
|
||||
50,
|
||||
);
|
||||
out.normal = normal.log.lines.map((l) => ({
|
||||
color: l.color,
|
||||
path: l.path.map((p) => p.map(round)),
|
||||
}));
|
||||
|
||||
// 직각 곡선 R 10 — 교각점 (0,0) · BC (-10,0) · EC (0,10)
|
||||
const right = {
|
||||
apex: [0, 0],
|
||||
radius_m: 10,
|
||||
tangent_m: 10,
|
||||
inner_angle_deg: 90,
|
||||
start: [-10, 0],
|
||||
end: [0, 10],
|
||||
node_first: 1,
|
||||
node_last: 1,
|
||||
violations: [],
|
||||
};
|
||||
out.mid = curve.curveMidVertex(right).map(round);
|
||||
|
||||
const unit = { toScreen: ([x, y]) => [x, y] };
|
||||
const picked = fakeContext();
|
||||
pick.drawPickedCurve(
|
||||
picked.ctx,
|
||||
{ ...unit, picked: 1, curveInfo: [right], curveOn: [true, true, true] },
|
||||
style,
|
||||
);
|
||||
out.picked = {
|
||||
dots: picked.log.dots.map(([x, y]) => [round(x), round(y)]),
|
||||
tangent: picked.log.lines[0].path,
|
||||
arcWidths: picked.log.lines.slice(1).map((l) => l.width),
|
||||
};
|
||||
const off = fakeContext();
|
||||
pick.drawPickedCurve(
|
||||
off.ctx,
|
||||
{ ...unit, picked: 1, curveInfo: [right], curveOn: [true, false, true] },
|
||||
style,
|
||||
);
|
||||
const none = fakeContext();
|
||||
pick.drawPickedCurve(
|
||||
none.ctx,
|
||||
{ ...unit, picked: -1, curveInfo: [right], curveOn: [true, true, true] },
|
||||
style,
|
||||
);
|
||||
out.offDrawn = off.log.strokes.length + none.log.strokes.length;
|
||||
|
||||
const tri = fakeContext();
|
||||
pick.traceNodeTriangle(tri.ctx, 100, 100, 6);
|
||||
out.triangle = tri.path().map((p) => p.map(round));
|
||||
console.log(JSON.stringify(out));
|
||||
@@ -165,8 +165,51 @@ const afterInsert = JSON.parse(JSON.stringify(columns));
|
||||
edits.spliceNode(columns, 1);
|
||||
const afterRemove = JSON.parse(JSON.stringify(columns));
|
||||
|
||||
// ⑤ Ctrl+휠 = 5° 돌리기(390-3) · 손가락 · 트랙패드 벌림 · 맨 휠 = 확대 · rotateWheel 없으면(추천 지도) 확대
|
||||
const input = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Input.ts"));
|
||||
const rotateMod = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Rotate.ts"));
|
||||
function wheelCase(withRotate, event) {
|
||||
const handlers = {};
|
||||
const canvas = {
|
||||
addEventListener: (name, fn) => (handlers[name] = fn),
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 400, height: 300 }),
|
||||
};
|
||||
let wheelView = {
|
||||
width: 400,
|
||||
height: 300,
|
||||
scale: 1,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
mapRect: { width: 400 },
|
||||
};
|
||||
let changes = 0;
|
||||
const rotation = rotateMod.createMapRotation({
|
||||
overlay: { querySelector: () => null },
|
||||
size: () => wheelView,
|
||||
onChange: () => (changes += 1),
|
||||
});
|
||||
input.bindRouteEditNavigation({
|
||||
canvas,
|
||||
getView: () => wheelView,
|
||||
setView: (next) => (wheelView = next),
|
||||
getMeta: () => null,
|
||||
rotateWheel: withRotate ? rotation.wheel : undefined,
|
||||
draw: () => {},
|
||||
});
|
||||
handlers.wheel({ clientX: 200, clientY: 150, preventDefault: () => {}, ...event });
|
||||
return { deg: (rotation.radians() * 180) / Math.PI, scale: wheelView.scale, changes };
|
||||
}
|
||||
const wheel = {
|
||||
ctrlDown: wheelCase(true, { ctrlKey: true, deltaY: 100 }),
|
||||
ctrlUp: wheelCase(true, { ctrlKey: true, deltaY: -100 }),
|
||||
trackpad: wheelCase(true, { ctrlKey: true, deltaY: -4 }),
|
||||
plain: wheelCase(true, { ctrlKey: false, deltaY: -100 }),
|
||||
recommend: wheelCase(false, { ctrlKey: true, deltaY: 100 }),
|
||||
};
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
wheel,
|
||||
saved,
|
||||
again,
|
||||
readBack,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""224-1b(PLAN 325-2 · 354-1) 유토곡선 판 높이 나눔 · 방향키.
|
||||
"""224-1b(PLAN 325-2) 유토곡선 판 「붙어 있음」 판정 · 방향키.
|
||||
|
||||
- 유토곡선 = 종단 열 아래 자기 판(354-1) — 높이 나눔 · 끌기에 안 낌(본문 높이에서 이미 빠짐) ·
|
||||
인라인 임시 축소도 안 씀. 나눔은 그래프 · 표 둘.
|
||||
- 높이 나눔 · 끌기는 「열림 && 종단 뿌리 안」만 자리를 줌 — 새 창으로 뗀 유토 판은 메인 자리 0 ·
|
||||
임시 축소 인라인 높이도 안 씀(뗀 창 높이가 굳지 않게).
|
||||
- 뗀 유토 판(`is-popout`)은 메인 보이는 구간 세로 범위(viewRange)를 안 씀 — 노선 전체 세로.
|
||||
- 방향키 ←→ 판정은 마지막 누름이 종단 뿌리 · 뗀 유토 · 뗀 배수 판 안이면 종단도 몫.
|
||||
|
||||
@@ -33,12 +33,13 @@ def r():
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
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):
|
||||
"""본문 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):
|
||||
@@ -51,15 +52,12 @@ def test_방향키_판정(r):
|
||||
}
|
||||
|
||||
|
||||
def test_끌기도_유토_판_안_셈():
|
||||
def test_끌기도_같은_판정():
|
||||
drag = (B05 / "B05_Profile_UI_Profile_Drag.ts").read_text(encoding="utf-8")
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
def test_뗀_유토는_노선_전체_세로():
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""224-1c(PLAN 325-3) 유토곡선 · 배수유역 [창 분할] — 종단 [창 분할]과 같은 양식 · 따로 새 창.
|
||||
|
||||
- 단추 = 두 판 머리줄 맨 앞(유토는 펼친 때만 보임 · 367-2) · 글 [창 분할]/[창 결합] · 툴팁에 판 이름.
|
||||
- 단추 = 유토 판 좌상단 · 배수 머리줄 맨 앞 · 글 [창 분할]/[창 결합] · 툴팁에 판 이름.
|
||||
- 창 이름 셋 다름(같으면 앞 창 body 를 덮어 조각이 사라짐).
|
||||
- 뗌 = 새 창 body · 뗀 유토 판 손잡이 · 리사이저는 CSS 로 감춤 · 배수 지도 관찰 rehome.
|
||||
- 붙임(단추 · 새 창 닫힘 · 페이지 떠남) = 제자리 — 종단 열 끝(종단 칸 아래) · 배수 content 끝.
|
||||
- 뗌 = 새 창 body · 유토 손잡이 숨김 · 인라인 높이 비움 · 배수 지도 관찰 rehome.
|
||||
- 붙임(단추 · 새 창 닫힘 · 페이지 떠남) = 제자리 — 유토 손잡이 바로 앞 · 배수 content 끝.
|
||||
- 종단 새 창 안에서 다시 떼면 그 창이 새 창을 엶(메인 창 팝업 차단 피함).
|
||||
|
||||
`helper_224_1c_subpanel_popout.cjs` 가 진짜 TS 를 가짜 DOM · 가짜 창 위에서 돌림.
|
||||
@@ -51,13 +51,14 @@ 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",
|
||||
}
|
||||
@@ -69,7 +70,8 @@ def test_배수_뗌은_rehome(r):
|
||||
|
||||
def test_새_창_닫힘과_단추로_제자리(r):
|
||||
assert r["massClosed"] == {
|
||||
"order": ["b05-route-profile__body-wrap", "b05-profile__masshaul-overlay"],
|
||||
"order": ["b05-profile__masshaul-overlay", "b05-profile__masshaul-handle"],
|
||||
"handleHidden": False,
|
||||
"isPopout": False,
|
||||
"text": "창 분할",
|
||||
}
|
||||
@@ -88,8 +90,9 @@ def test_페이지_떠나면_셋_다_닫고_제자리(r):
|
||||
assert r["disposed"] == {
|
||||
"closed": [True, True, True],
|
||||
"profileInSlot": True,
|
||||
"massInColumnEnd": True,
|
||||
"massBeforeHandle": True,
|
||||
"drainInContent": True,
|
||||
"handleHidden": False,
|
||||
"original": 1,
|
||||
}
|
||||
|
||||
@@ -101,5 +104,4 @@ 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
|
||||
|
||||
@@ -67,8 +67,7 @@ def test_높이_맞춤(r):
|
||||
|
||||
def test_표_유토곡선_크기_끌기_다시_그림(r):
|
||||
assert r["table"] == {"changed": 1, "handles": 2, "mainTimers": 0}
|
||||
# 유토 판 손잡이는 머리줄에 앉아 끌기 때 따라 옮길 것이 없음(354-1)
|
||||
assert r["massHaul"] == {"changed": 1, "handles": 0, "mainTimers": 0}
|
||||
assert r["massHaul"] == {"changed": 1, "handles": 2, "mainTimers": 0}
|
||||
|
||||
|
||||
def test_유토곡선_세로창_미끄러짐(r):
|
||||
|
||||
@@ -205,7 +205,8 @@ def test_화면_기준점_여럿_등록_삭제():
|
||||
state = _read(B08 + "UI_Milestone_State.ts")
|
||||
assert "migrateMilestone(" in state and "bases: []," in state
|
||||
page = _read(B08 + "UI_Milestone.ts")
|
||||
assert "data.bases.length > 0" in page and 'to: "site",' in page
|
||||
assert 'to: "site",' in page
|
||||
assert "data.bases.length > 0" in _read(B08 + "Milestone_Table.ts") # 302-3 표로 옮김
|
||||
for name in ("Milestone_Calc.ts", "Milestone_Layout.ts", "UI_Milestone_Diagram.ts",
|
||||
"UI_Milestone_Modal.ts", "UI_Milestone_State.ts", "UI_Milestone_LegModal.ts",
|
||||
"UI_Milestone_Route.ts", "UI_Milestone.ts"): # fmt: skip
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"""311 좌측 패널 기본 접힘 · B07 첫 쪽 = 표지 (요구 204).
|
||||
|
||||
못박는 것
|
||||
- 기본 접힘 = B09 「데이터 점검」 · 「공기 옵션」 / B07 「평면도」 · 「종단도」 · 「횡단도」 /
|
||||
B06 「횡단 보기 설정」 / B05 「계획노선」 · 다른 컨테이너 기본값 그대로.
|
||||
- 기본 접힘(389-3) = 좌측 카드 전부 — B09 「데이터 점검」 · 「공기 옵션」 · 인쇄 둘 / B07 묶음 여섯 /
|
||||
B06 「횡단 보기 설정」 / B05 「페이지 설정」 · 「노선 추천」 · 「구조물 배치」 / B08 · M02 집계 양식.
|
||||
- 쪽 안에서 편 것은 다시 그려도 그대로 — B05 · B06 = 한 벌 패널이 칸을 한 번만 세움 · B07 = 다시 세울 때
|
||||
옛 목록 접힘을 이어 받음 · B09 = 좌측 패널을 쪽 그릴 때 한 번만 세움.
|
||||
- B07 처음 들어가면 표지 — 고른 장 기억(모듈 값 `reopenDrawingId` · `reopenCrossSheet`)이 있으면 그 장.
|
||||
@@ -42,7 +42,7 @@ def out():
|
||||
def test_B05_B06_첫_그림_접힘(out):
|
||||
assert out["profileFirst"] == {
|
||||
"페이지 설정": True,
|
||||
"노선 추천": False, # 345-4 단추 칸 — 펼친 채
|
||||
"노선 추천": True, # 389-3 모든 좌측 카드 기본 닫힘
|
||||
}
|
||||
assert out["crossFirst"]["B06_View_Title"] is True
|
||||
assert out["crossFirst"]["B06_Std_Title"] is True # 옛 기본 접힘 그대로
|
||||
@@ -63,9 +63,9 @@ def test_B07_첫_그림_접힘(out):
|
||||
"plan": True,
|
||||
"longitudinal": True,
|
||||
"cross": True,
|
||||
"mass_haul": False,
|
||||
"structure": False, # 376-2 늘 카드 · 기본 펼침
|
||||
"development": False, # 377 전개도 카드 — 기본 펼침
|
||||
"mass_haul": True,
|
||||
"structure": True, # 389-3 토적도 · 구조물 도면 · 전개도 도 기본 닫힘
|
||||
"development": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -75,8 +75,8 @@ def test_B07_다시_세워도_접힘_이어_받음(out):
|
||||
"longitudinal": True,
|
||||
"cross": False,
|
||||
"mass_haul": True,
|
||||
"structure": False,
|
||||
"development": False,
|
||||
"structure": True,
|
||||
"development": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -109,3 +109,23 @@ def test_B09_기본_접힘_좌측은_한_번만_세움():
|
||||
assert "createPanelCard" not in show and "left." not in show
|
||||
print_page = _read("B09_Estimation/draft/B09_Draft_Print_Page.ts")
|
||||
assert 'createPanelCard("영역")' in print_page and 'createPanelCard("쪽 목록")' in print_page
|
||||
|
||||
|
||||
def test_389_3_공용_카드_기본값_닫힘_그_밖_자리():
|
||||
assert "createPanelCard(title: string, collapsed = true)" in _read(
|
||||
"ui_template/ui_template_collapsible.ts"
|
||||
)
|
||||
assert "collapsed = true,\n): { root: HTMLElement; body: HTMLElement }" in _read(
|
||||
"A00_Common/design/design_panel.ts"
|
||||
).replace("\r\n", "\n")
|
||||
form = _read("B05_Profile/B05_Profile_UI_Structures_Form.ts")
|
||||
assert "ui-collapsible ui-sidebar-section is-collapsed" in form
|
||||
# 우클릭 추가 = 구조물 배치 카드를 폄(116-1) 그대로
|
||||
assert 'root.classList.remove("is-collapsed");' in _read(
|
||||
"B05_Profile/B05_Profile_UI_Structures_Panel.ts"
|
||||
)
|
||||
# M02 구조물 집계 양식도 처음엔 닫힘 — 열림 해제 줄 없음
|
||||
assert 'summary.classList.remove("is-collapsed")' not in _read(
|
||||
"M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts"
|
||||
)
|
||||
assert "FOLDED_KINDS" not in _read("B07_DesignDetail/B07_DesignDetail_UI_Panels.ts")
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""354-1 유토곡선 = 배수유역도처럼 자기 판(요구 235-7).
|
||||
"""354-1 → 392-2 유토곡선 판 = 10-14 판(9edaec82e) 되살림(요구 242-10).
|
||||
|
||||
- 종단 열(`b05-route-profile__column`) = [종단 칸(bodyWrap), 유토 판] · content 안이라 종단 [창 분할]
|
||||
(`.is-popout > :not(content)` 숨김)에도 같이 감 · B05 · B06 이 같은 `design_profile` 하나.
|
||||
- 유토 판은 흐름 칸(겹침 아님) · 자기 리사이저 높이 · 접으면 높이 0 · 손잡이만 매달림 · [창 분할] 숨김(367-2).
|
||||
- 높이 나눔 · 표 쌓기에서 빠짐(표 bottomOffset 없음) · X 동기화 없음(367-1 늘 전체 맞춤).
|
||||
- 유토곡선 = 종단 칸(bodyWrap) 바닥 덮는 판(absolute · z5) · 자기 판(종단 열 아래 칸) 지움.
|
||||
- 메인 판 끌기 = 종단 · 유토 · 표 세 영역 같은 비율 · 놓을 때 유토 · 표 높이 확정(commitHeight).
|
||||
- 유토 손잡이 = 판 위 경계 밖(bottom = 판 높이) · 표 손잡이와 ±44 px 나란히 · 접기 = 숨김.
|
||||
- 표 판 = 열린 유토 위에 쌓임(setBottomOffset).
|
||||
- 남김 = 유토 [창 분할](판 안 좌상단 = 펼친 때만 보임) · 367-1 늘 전체 맞춤 · 325-2 뿌리 안 판정.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -25,73 +28,115 @@ def css_block(css: str, selector: str) -> str:
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_종단_열_아래_자기_칸():
|
||||
# 진짜 높이 나눔(createHeightCascade) — 메인 끌기 중 세 영역 비례
|
||||
NODE = r"""
|
||||
const path = require("path"), fs = require("fs");
|
||||
const root = process.argv[1];
|
||||
const ts = require(path.join(root, "config", "node_modules", "typescript"));
|
||||
const src = fs.readFileSync(path.join(root, "B05_Profile", "B05_Profile_UI_Profile_Heights.ts"), "utf8");
|
||||
const js = ts.transpileModule(src, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
|
||||
const exp = {};
|
||||
new Function("exports", "require", "window", js)(exp, (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 },
|
||||
{ innerHeight: 900 });
|
||||
const el = () => { const n = { hidden: false, style: { height: "" }, parent: null };
|
||||
n.append = (k) => ((k.parent = n), k);
|
||||
n.contains = (o) => { for (let a = o; a; a = a.parent) if (a === n) return true; return false; };
|
||||
return n; };
|
||||
function run(bodyH) {
|
||||
const rootEl = el(); rootEl.offsetHeight = bodyH + 100;
|
||||
const body = rootEl.append(el()); body.clientHeight = bodyH;
|
||||
const mass = body.append(el()), table = body.append(el());
|
||||
const seen = { handle: 0, offset: null };
|
||||
const c = exp.createHeightCascade({
|
||||
root: rootEl, body,
|
||||
massHaul: { overlay: mass, desiredHeight: () => 200, syncHandle: () => seen.handle++ },
|
||||
tableOverlay: { overlay: table, isOpen: () => true, desiredHeight: () => 200,
|
||||
setBottomOffset: (v) => (seen.offset = v) },
|
||||
isMainDragging: () => true, isSubDragging: () => false,
|
||||
// 끌기 시작 = 종단 400 · 유토 200 · 표 200
|
||||
mainDragRef: () => ({ chart: 400, mass: 200, table: 200 }),
|
||||
isMainDragCooldown: () => true, hasDetail: () => true,
|
||||
});
|
||||
const { chartHeight } = c.apply(false);
|
||||
return { chart: chartHeight, mass: mass.style.height, table: table.style.height, ...seen };
|
||||
}
|
||||
// 본문 − 알약 40 − 손잡이 띠 13 = 나눌 자리 · 800 → 1.0 배 · 1200 → 1.5 배
|
||||
process.stdout.write(JSON.stringify({ same: run(853), grow: run(1253) }));
|
||||
"""
|
||||
|
||||
|
||||
def test_메인_끌기_세_영역_같은_비율():
|
||||
res = subprocess.run(
|
||||
["node", "-e", NODE, str(ROOT)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
out = json.loads(res.stdout)
|
||||
assert out["same"] == {
|
||||
"chart": 400,
|
||||
"mass": "200px",
|
||||
"table": "200px",
|
||||
"handle": 1,
|
||||
"offset": 200,
|
||||
}
|
||||
# 1.5 배 — 셋 다 같은 비율 · 표는 유토 위에 쌓임(바닥 간격 = 유토 높이)
|
||||
assert out["grow"] == {
|
||||
"chart": 600,
|
||||
"mass": "300px",
|
||||
"table": "300px",
|
||||
"handle": 1,
|
||||
"offset": 300,
|
||||
}
|
||||
|
||||
|
||||
def test_놓을_때_높이_확정_뿌리_안_판정():
|
||||
drag = read("B05_Profile/B05_Profile_UI_Profile_Drag.ts")
|
||||
assert "if (massDocked()) massHaul.commitHeight(massHaul.overlay.offsetHeight);" in drag
|
||||
assert "if (tableOverlay.isOpen()) tableOverlay.commitHeight(" in drag
|
||||
# 325-2 「열림 && 뿌리 안」 한 곳 · 하한에 유토 최소
|
||||
assert drag.count("massHaul.overlay.hidden") == 1
|
||||
assert "root.contains(massHaul.overlay)" in drag
|
||||
assert "const massMin = massDocked() ? MASSHAUL_MIN_HEIGHT : 0;" in drag
|
||||
heights = read("B05_Profile/B05_Profile_UI_Profile_Heights.ts")
|
||||
assert '{ key: "mass", ref: Math.max(1, dragRef.mass), min: MASSHAUL_MIN_HEIGHT }' in heights
|
||||
|
||||
|
||||
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 "bodyWrap.append(massHaul.overlay, massHaul.handle);" in overlays
|
||||
assert "b05-route-profile__column" not in overlays
|
||||
assert "massHaul.attachScrollSync" not in overlays # 367-1 측점 연계 없앰
|
||||
profile = read("A00_Common/design/design_profile.ts")
|
||||
assert "content.append(column);" in profile
|
||||
assert "content.append(bodyWrap);" 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
|
||||
# 367-2 접음 = 높이 0 · 손잡이 판 위 경계에 매달림 · [창 분할] 숨김(뗀 창 [창 결합] 그대로)
|
||||
folded = css_block(css, ".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout)")
|
||||
assert "height: 0;" in folded
|
||||
head = css_block(
|
||||
css,
|
||||
".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > .b05-profile__masshaul-head",
|
||||
)
|
||||
assert "top: auto;" in head and "bottom: 100%;" in head
|
||||
assert "display: none;" in css_block(
|
||||
css, ".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) .b05-subpanel__popout"
|
||||
)
|
||||
assert ".is-popout .b05-subpanel__popout" not in css
|
||||
assert "display: none;" in css_block(
|
||||
css, ".b05-route-profile.is-collapsed .b05-profile__masshaul-overlay"
|
||||
)
|
||||
assert "position: absolute;" in panel and "bottom: 0;" in panel and "z-index: 5;" in panel
|
||||
assert "masshaul-head" not in css and "is-collapsed:not(.is-popout)" not in css
|
||||
|
||||
|
||||
def test_손잡이_자리_접기_숨김():
|
||||
css = read("B05_Profile/B05_Profile_UI_Style_MassHaul.css")
|
||||
assert "left: calc(50% + 44px);" in css_block(css, ".b05-profile__masshaul-handle")
|
||||
assert "left: calc(50% - 44px);" in css_block(css, ".b05-profile__table-handle")
|
||||
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")
|
||||
# 손잡이 = 판 위 경계 밖 · 접으면 바닥 · 접기 = 판 숨김
|
||||
assert 'handle.style.bottom = open ? `${overlay.offsetHeight}px` : "0px";' in mass
|
||||
assert "overlay.hidden = !open;" in mass and "overlay.hidden = !next;" in mass
|
||||
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
|
||||
assert "overlay.style.bottom = `${bottomOffset}px`;" in table
|
||||
|
||||
|
||||
def test_창_분할_판_좌상단():
|
||||
popout = read("A00_Common/design/design_profile_popout.ts")
|
||||
assert "massOverlay.append(button);" in popout
|
||||
assert "home: () => massHandle.before(massOverlay)," in popout
|
||||
page = read("A00_Common/design/design_page.css")
|
||||
assert "position: absolute;" in css_block(
|
||||
page, ".b05-profile__masshaul-overlay > .b05-subpanel__popout"
|
||||
)
|
||||
|
||||
@@ -65,6 +65,21 @@ def test_맞춘_선은_다시_맞춰도_그대로_간격_0_은_안_맞춤(got) -
|
||||
assert max(got["again"]) == 0 and max(got["noInterval"]) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("step", ["0.1", "1", "10"])
|
||||
def test_스냅_간격_배수에_맞춤(got, step) -> None:
|
||||
"""390-2 — MC 를 고른 간격 배수에 · 측점 밖 개수도 같은 간격 · 밀림은 간격 절반 안."""
|
||||
size = float(step)
|
||||
case = got["steps"][step]
|
||||
assert case["offBefore"] == 5 and case["off"] == 0 and case["curves"] == 5
|
||||
assert max(case["residues"]) <= min(TOL, size / 20)
|
||||
assert max(case["moved"]) <= size / 2 + 1e-6
|
||||
assert case["moved"][0] == 0 and case["moved"][-1] == 0
|
||||
|
||||
|
||||
def test_허용_오차는_간격_따라(got) -> None:
|
||||
assert got["tol"] == pytest.approx([0.005, 0.05, 0.05, 0.05])
|
||||
|
||||
|
||||
def test_시작_자리는_고친_꺾임점_앞(got) -> None:
|
||||
start = got["start"]
|
||||
assert start["moved4"] == 3 and start["radius2"] == 1
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
B06 꼴(가운데 카드 칸 + 아래 종단 판)을 진짜 CSS 로 세우고 판을 펼쳤다 접은 **바로 뒤**
|
||||
카드 자리 elementFromPoint = 카드. 높이 transition 이 있으면 그 순간 판이 아직 펼친 높이라
|
||||
`b05-route-profile__column` 이 누름을 받음(그려지지 않는 탭에선 전환이 시작도 안 해 그대로 멈춤).
|
||||
종단 판(`b05-route-profile__*`)이 누름을 받음(그려지지 않는 탭에선 전환이 시작도 안 해 그대로 멈춤).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
@@ -24,9 +24,9 @@ HTML = """
|
||||
</div></div>
|
||||
<div class="design-page__profile">
|
||||
<section class="b05-route-profile" style="--b05-profile-height:600px">
|
||||
<div class="b05-route-profile__content"><div class="b05-route-profile__column">
|
||||
<div class="b05-route-profile__content">
|
||||
<div class="b05-route-profile__body-wrap"><div class="b05-route-profile__body"></div></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""367-6 유토곡선 손잡이 · 범례 안 겹침 — 실제 브라우저(서버 없이 · 진짜 CSS).
|
||||
"""367-6 → 392-2 유토곡선 손잡이 · 범례 안 겹침 — 실제 브라우저(서버 없이 · 진짜 CSS).
|
||||
|
||||
유토 판 머리줄(손잡이 = 중앙 +44)과 범례(오른쪽 붙임)를 진짜 CSS 로 세우고 판 폭마다
|
||||
범례 단추 셋 · 손잡이 가운데 elementFromPoint = 그 단추. 좁은 폭 = 배수유역 판이 펼친 B06 ·
|
||||
B05 종단 열(≈ 434px) · 더 좁은 열 · 넓은 폭 = 배수유역 접은 칸(1162px) — 넓을 땐 한 줄 그대로.
|
||||
10-14 판(392-2) — 유토 손잡이는 판 위 경계 **밖**(bottom = 판 높이 · JS 가 넣음)이라 판 안
|
||||
범례(오른쪽 붙임)와 안 겹침 · 접힌 표 손잡이(바닥 간격 = 유토 높이)와 ±44 px 나란히.
|
||||
판 폭마다 범례 단추 셋 · 두 손잡이 가운데 elementFromPoint = 그 단추.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
@@ -15,26 +15,33 @@ CSS = [
|
||||
"B05_Profile/B05_Profile_UI_Style_MassHaul.css",
|
||||
]
|
||||
BUTTONS = ["도형 위치 초기화", "횡단 기준", "토량 분배"]
|
||||
HTML = """
|
||||
<div class="b05-profile__masshaul-overlay" style="width:{w}px;margin-top:40px">
|
||||
<div class="b05-profile__masshaul-head"><div class="b05-profile__masshaul-handle">
|
||||
HANDLE = """<div class="b05-profile__{k}-handle" style="bottom:280px">
|
||||
<button class="ui-workflow-overlay__handle ui-workflow-overlay__handle--bottom is-labeled">
|
||||
<span class="ui-workflow-overlay__handle-icon"></span>
|
||||
<span class="ui-workflow-overlay__handle-label">유토곡선</span></button>
|
||||
</div></div>
|
||||
<div class="b05-profile__masshaul-scroll"></div>
|
||||
<div class="b05-profile__masshaul-legend"><div class="b06-masshaul__legend">
|
||||
<button class="b06-masshaul__legend-item b06-masshaul__legend-item--reset">도형 위치 초기화</button>
|
||||
<button class="b06-masshaul__legend-item"><span class="b06-masshaul__legend-swatch"></span><span>횡단 기준</span></button>
|
||||
<button class="b06-masshaul__legend-item"><span class="b06-masshaul__legend-swatch"></span><span>토량 분배</span></button>
|
||||
</div></div>
|
||||
</div>
|
||||
"""
|
||||
<span class="ui-workflow-overlay__handle-label">{label}</span></button></div>"""
|
||||
HTML = (
|
||||
"""
|
||||
<div class="b05-route-profile__body-wrap" style="display:flex;width:{w}px;height:520px">
|
||||
<div class="b05-profile__masshaul-overlay">
|
||||
<div class="b05-profile__masshaul-scroll"></div>
|
||||
<div class="b05-profile__masshaul-legend"><div class="b06-masshaul__legend">
|
||||
<button class="b06-masshaul__legend-item b06-masshaul__legend-item--reset">도형 위치 초기화</button>
|
||||
<button class="b06-masshaul__legend-item"><span class="b06-masshaul__legend-swatch"></span><span>횡단 기준</span></button>
|
||||
<button class="b06-masshaul__legend-item"><span class="b06-masshaul__legend-swatch"></span><span>토량 분배</span></button>
|
||||
</div></div>
|
||||
</div>
|
||||
"""
|
||||
+ HANDLE.replace("{k}", "masshaul").replace("{label}", "유토곡선")
|
||||
+ HANDLE.replace("{k}", "table").replace("{label}", "테이블")
|
||||
+ "\n</div>"
|
||||
)
|
||||
PROBE = """() => [...document.querySelectorAll('button')].map((b) => {
|
||||
const r = b.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
return [b.textContent.trim(), b === hit || b.contains(hit), Math.round(r.top)];
|
||||
return [b.textContent.trim(), b === hit || b.contains(hit), Math.round(r.top),
|
||||
Math.round(r.left + r.width / 2), Math.round(b.parentElement.getBoundingClientRect().bottom)];
|
||||
})"""
|
||||
TOP = "Math.round(document.querySelector('.b05-profile__masshaul-overlay').getBoundingClientRect().top)"
|
||||
|
||||
|
||||
def test_legend_buttons_clear_of_handle():
|
||||
@@ -47,9 +54,11 @@ def test_legend_buttons_clear_of_handle():
|
||||
for width in (360, 434, 700, 1162):
|
||||
page.set_content(f"<style>body{{margin:0}}{css}</style>{HTML.format(w=width)}")
|
||||
rows = page.evaluate(PROBE)
|
||||
assert [r[0] for r in rows] == ["유토곡선", *BUTTONS]
|
||||
assert [r[0] for r in rows] == [*BUTTONS, "유토곡선", "테이블"]
|
||||
assert all(r[1] for r in rows), (width, rows)
|
||||
if width == 1162:
|
||||
# 넓을 땐 지금 모양 — 범례 한 줄
|
||||
assert len({r[2] for r in rows[1:]}) == 1, rows
|
||||
mass, table = rows[3], rows[4]
|
||||
# 손잡이 = 판 위 경계 밖(손잡이 바닥 = 판 위) · 두 손잡이 한 줄 · 가운데 ±44
|
||||
assert mass[4] == page.evaluate(TOP), (width, rows)
|
||||
assert mass[2] == table[2]
|
||||
assert mass[3] - table[3] == 88
|
||||
browser.close()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""381-2 B05 평면 [측점] [기울기] 켬끔(요구 241-11).
|
||||
|
||||
- 단추 = 절/성토선 왼쪽 · 켬끔 모듈(기본 켬 · 페이지 동안)
|
||||
- 단추 = 절/성토선 왼쪽 · 켬끔 모듈(기본 [측점] 켬 · [기울기] 끔(390-1 요구 242-17) · 페이지 동안)
|
||||
- [측점] 끔 = 눈금 · 번호 안 그림 · 눈금 집기 안 함(라벨 0개 = 전부 집힘 함정) · 시점 · 종점 이름표 남김
|
||||
- [기울기] 끔 = 기울기 라벨(높이 묻기) 안 부름
|
||||
"""
|
||||
@@ -14,9 +14,9 @@ def _read(name: str) -> str:
|
||||
return (B05 / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_켬끔_모듈_기본_켬():
|
||||
def test_켬끔_모듈_기본_측점_켬_기울기_끔():
|
||||
marks = _read("B05_Profile_UI_RouteEdit_Marks.ts")
|
||||
assert "{ stations: true, slopes: true }" in marks
|
||||
assert "{ stations: true, slopes: false }" in marks
|
||||
assert "scheduleRedraw();" in marks
|
||||
assert "export function scheduleRedraw" in _read("B05_Profile_UI_RouteEdit_Footprint.ts")
|
||||
|
||||
|
||||
@@ -47,4 +47,9 @@ def test_집기_맴돔_한_함수():
|
||||
def test_맴돔_그림():
|
||||
hover = _read("B05_Profile_UI_RouteEdit_Hover.ts")
|
||||
assert "pickHover(context.canvas)" in hover
|
||||
assert '"node" in hover' in hover and "stationFrameAt(line, hover.chainage)" in hover
|
||||
assert (
|
||||
'"node" in hover' in hover
|
||||
and "drawStationNormal(context, scene, line, hover.chainage)" in hover
|
||||
)
|
||||
# 391-1 맴돔 = 고름 한 함수 · 좌우 20 m 고정
|
||||
assert "const STATION_NORMAL_HALF_M = 20;" in hover
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""391 B05 평면 그림(요구 242-5 · 6 · 12 · 13 · 15) — 20 m 법선 · 곡선 3점 · 삼각형 · 잠금 색.
|
||||
|
||||
Node 헬퍼(`helper_391_routeedit_plan_draw.cjs`)가 TS 를 트랜스파일해 가짜 캔버스로 돌림.
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_391_routeedit_plan_draw.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
B05 = ROOT / "B05_Profile"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_391_routeedit_plan_draw.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _read(name: str) -> str:
|
||||
return (B05 / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_법선은_좌우_20m_하늘색(results):
|
||||
line = results["normal"][0]
|
||||
assert line["color"] == "#0ea5e9"
|
||||
# 2 px/m · 동서 노선 50 m 자리 → 좌(북) 20 m · 우(남) 20 m
|
||||
assert line["path"] == [[100, 40], [100, -40]]
|
||||
|
||||
|
||||
def test_맴돔과_고름이_한_함수():
|
||||
hover = _read("B05_Profile_UI_RouteEdit_Hover.ts")
|
||||
assert "drawStationNormal(context, scene, line, hover.chainage);" in hover
|
||||
assert "* 12" not in hover # 짧은 12 px 선 지움
|
||||
render = _read("B05_Profile_UI_RouteEdit_Render.ts")
|
||||
assert "drawStationNormal(context, scene, line, watch.chainageM);" in render
|
||||
assert "leftCut" not in render and "--b05-footprint-cut" not in render
|
||||
assert 'context.strokeStyle = "#ff9800";' in render # 구조물 마름모 그대로
|
||||
|
||||
|
||||
def test_MC_는_호_가운데(results):
|
||||
r = 10 * (1 - 1 / math.sqrt(2))
|
||||
assert results["mid"] == [round(-r, 3), round(r, 3)]
|
||||
|
||||
|
||||
def test_고른_곡선_세_점_접선_굵은_호(results):
|
||||
picked = results["picked"]
|
||||
assert picked["dots"][0] == [-10, 0] and picked["dots"][2] == [0, 10]
|
||||
assert picked["dots"][1] == results["mid"]
|
||||
assert picked["tangent"] == [[-10, 0], [0, 0], [0, 10]]
|
||||
assert picked["arcWidths"] == [8, 5] # 흰 테 · 하늘색
|
||||
|
||||
|
||||
def test_곡선_없음_고름_없음은_안_그림(results):
|
||||
assert results["offDrawn"] == 0
|
||||
|
||||
|
||||
def test_곡선_없는_교각점은_삼각형(results):
|
||||
tri = results["triangle"]
|
||||
assert len(tri) == 3 and tri[0] == [100, round(100 - 6 * 1.4, 3)]
|
||||
render = _read("B05_Profile_UI_RouteEdit_Render.ts")
|
||||
assert "if (noCurve) traceNodeTriangle(context, x, y, radius);" in render
|
||||
assert "drawPickedCurve(context, scene, style);" in render
|
||||
assert render.index("drawPickedCurve(") < render.index("drawPickHover(context")
|
||||
|
||||
|
||||
def test_잠금_황금_굵기4_흰테():
|
||||
css = (ROOT / "ui_template" / "ui_template_theme.css").read_text(encoding="utf-8")
|
||||
assert css.count("--map-route-locked: #ca8a04;") == 1
|
||||
assert css.count("--map-route-locked: #facc15;") == 2 # 어두움 · 시스템 어두움
|
||||
render = _read("B05_Profile_UI_RouteEdit_Render.ts")
|
||||
assert "const LOCKED_WIDTH = 4;" in render
|
||||
assert "piece, [], halo, LOCKED_WIDTH + 3);" in render
|
||||
|
||||
|
||||
def test_파일_700줄_안():
|
||||
for name in (
|
||||
"B05_Profile_UI_RouteEdit_Render.ts",
|
||||
"B05_Profile_UI_RouteEdit_Hover.ts",
|
||||
"B05_Profile_UI_RouteEdit_Curve_Pick.ts",
|
||||
):
|
||||
assert len(_read(name).splitlines()) <= 700, name
|
||||
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""배수유역 판 기본 닫힘(계획서 392-1).
|
||||
|
||||
진짜 `b_page_state.ts` 를 가짜 저장소 위에서 돌림 — 옛 기억("false" · 옛 키 · 판 없는 키)은
|
||||
버리고 기억 없음 = null. 판은 "false"(사용자가 펼친 기억)만 펼침 · B06 도 같은 전역 키.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(os.path.dirname(HERE))
|
||||
|
||||
NODE = r"""
|
||||
const path = require("path"), fs = require("fs");
|
||||
const ts = require(path.join(process.argv[1], "config", "node_modules", "typescript"));
|
||||
const src = fs.readFileSync(path.join(process.argv[1], "A00_Common", "b_page_state.ts"), "utf8");
|
||||
const js = ts.transpileModule(src, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
|
||||
const mk = (init) => { const m = new Map(Object.entries(init)); return {
|
||||
getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)),
|
||||
removeItem: (k) => m.delete(k), key: (i) => [...m.keys()][i] ?? null, get length() { return m.size; }, _m: m }; };
|
||||
const local = mk({ "aislo:pref:drainage-collapsed": "false", "b05-route-drainage-collapsed": "false" });
|
||||
const window = { localStorage: local, sessionStorage: mk({}) };
|
||||
const exports = {};
|
||||
new Function("exports", "window", "require", js)(exports, window, () => ({}));
|
||||
const out = { key: exports.stateKey("drainage-collapsed"), old: exports.readStateRaw("drainage-collapsed") };
|
||||
exports.writeStateRaw("drainage-collapsed", "false");
|
||||
out.kept = exports.readStateRaw("drainage-collapsed");
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def test_old_memory_dropped_and_new_kept():
|
||||
res = subprocess.run(
|
||||
["node", "-e", NODE, ROOT], capture_output=True, text=True, encoding="utf-8", check=True
|
||||
)
|
||||
out = json.loads(res.stdout)
|
||||
assert out["key"] == "aislo:pref:drainage-collapsed-v2"
|
||||
assert out["old"] is None # 옛 "false" 두 키 모두 안 읽음
|
||||
assert out["kept"] == "false" # 새 기억은 그대로
|
||||
|
||||
|
||||
def test_panel_default_closed():
|
||||
with open(
|
||||
os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Drainage_Panel.ts"), encoding="utf-8"
|
||||
) as f:
|
||||
src = f.read()
|
||||
# 기억 없음(null) = 닫힘 · "false" 만 펼침
|
||||
assert 'setCollapsed(readStateRaw("drainage-collapsed") !== "false")' in src
|
||||
@@ -36,13 +36,8 @@ def test_컨테이너_맹암거_높이_경사_손_입력():
|
||||
("culvert_slope_pct", "암거 경사", "%"),
|
||||
):
|
||||
o = opts[key]
|
||||
assert (o.label, o.input, o.unit, o.default, o.required) == (
|
||||
label,
|
||||
"number",
|
||||
unit,
|
||||
None,
|
||||
False,
|
||||
)
|
||||
# 기본값 = 마스터 사용자 편집 몫 — 실제 값에 기대지 않음
|
||||
assert (o.label, o.input, o.unit, o.required) == (label, "number", unit, False)
|
||||
assert o.empty_means # 손 입력 · 기본값 근거 없음 — 비워 둘 수 있음(B05 단계 필수 금지)
|
||||
groups = load_definition()["그룹"][KEY]
|
||||
assert groups == [
|
||||
|
||||
@@ -49,10 +49,22 @@ def test_겉틀은_본문_칸():
|
||||
# 시험 · 배선이 집는 자리는 남김
|
||||
assert "b05-routeedit__measure-close" in chrome
|
||||
# 382-1 — 오른쪽 횡단 판 = 패널 양식(손잡이 · 머리 · 폭 끌기 · 열림 · 폭 기억) · 칸 높이를 채움
|
||||
assert "createSidePanels({" in chrome and 'prefKey: "b05-routeedit-cross"' in chrome
|
||||
assert "createSidePanels({" in chrome and 'prefKey: "b05-routeedit-cross-v2"' in chrome
|
||||
assert "follow: false" in chrome and 'data-act="side-toggle"' not in chrome
|
||||
|
||||
|
||||
def test_393_1_횡단_판_기본_접힘_자동_열림은_기억_안_함():
|
||||
chrome = _read("B05_Profile_UI_RouteEdit_Chrome.ts")
|
||||
assert "defaultOpen: null," in chrome and "innerWidth >= 1500" not in chrome
|
||||
# 측점 눈금 → 열되 기억 안 함 · 손잡이(누름)만 기억
|
||||
cross = _read("B05_Profile_UI_RouteEdit_Cross.ts")
|
||||
assert 'panels.open("cross", false)' in cross
|
||||
side = (ROOT / "ui_template" / "ui_template_side_panels.ts").read_text(encoding="utf-8")
|
||||
assert "function open(id: string | null, remember = true): void {" in side
|
||||
assert "JSON.stringify({ open: kept, width })" in side
|
||||
assert "if (remember) {\n kept = next;\n save();" in side
|
||||
|
||||
|
||||
def test_CSS_덮개_걷음_대기막은_전체():
|
||||
css = _read("B05_Profile_UI_Style_RouteEdit.css")
|
||||
root = _block(css, ".b05-routeedit")
|
||||
|
||||
@@ -208,7 +208,7 @@ def test_노선_추천_다듬기_23_3():
|
||||
return open(os.path.join(page, name), encoding="utf-8").read()
|
||||
|
||||
chrome = read("B05_Profile_UI_RouteEdit_Chrome.ts")
|
||||
# 366-2 — [?] | ↶ ↷ | 3D | ⟲ ⟳ · 1m 등고선 · 절/성토선 · 거리 재기 | 측점 스냅 · 분석 지형(맨 오른쪽)
|
||||
# 366-2 — [?] | ↶ ↷ | 3D | ⟲ ⟳ · 1m 등고선 · 절/성토선 · 거리 재기 | 측점 스냅 · 간격 | 분석 지형(맨 오른쪽)
|
||||
bar = chrome[
|
||||
chrome.index("b05-routeedit__tools") : chrome.index('<div class="b05-routeedit__hint">')
|
||||
]
|
||||
@@ -229,14 +229,20 @@ def test_노선_추천_다듬기_23_3():
|
||||
'data-act="measure"',
|
||||
"|",
|
||||
'data-act="station-snap"',
|
||||
'class="b05-routeedit__terrain"',
|
||||
'data-act="snap-step"', # 390-2 스냅 간격 = [측점 스냅] 오른쪽
|
||||
"|", # 390-1 [측점 스냅] | 분석 지형
|
||||
'class="b05-routeedit__terrain" hidden',
|
||||
]
|
||||
at = 0
|
||||
for mark in order:
|
||||
key = "b05-route__view-separator" if mark == "|" else mark
|
||||
at = bar.index(key, at) + 1
|
||||
assert bar.count("b05-route__view-separator") == 4
|
||||
assert chrome.count("b05-route__view-separator") == 4
|
||||
assert bar.count("b05-route__view-separator") == 5
|
||||
assert chrome.count("b05-route__view-separator") == 5
|
||||
# 분석 지형이 숨으면 앞 구분선도 숨김 · 스냅 끔이면 간격 고르기 흐림
|
||||
style = read("B05_Profile_UI_Style_RouteEdit.css")
|
||||
assert ".b05-route__view-separator:has(+ .b05-routeedit__terrain[hidden])" in style
|
||||
assert '[data-act="station-snap"][aria-pressed="false"] + .b05-routeedit__snap-step' in style
|
||||
assert "b05-routeedit__spin" not in chrome # 회전 = 도구줄(추천 모달 CSS 는 남음)
|
||||
shared = read("B05_Profile_UI_RouteEdit_Recommend_Map.ts")
|
||||
for name in ("bindRouteEditNavigation", "createMapRotation", "fitCanvas", "metricToScreen"):
|
||||
|
||||
@@ -84,3 +84,13 @@ def test_숨김_크기_0_뒤_다시_보이면_그대로(results):
|
||||
assert back["cx"] == pytest.approx(kept["cx"], abs=1e-6)
|
||||
assert back["cy"] == pytest.approx(kept["cy"], abs=1e-6)
|
||||
assert back["pxPerMeter"] == pytest.approx(kept["pxPerMeter"], rel=1e-9)
|
||||
|
||||
|
||||
def test_Ctrl_휠은_5도_돌리기_벌림과_맨_휠은_확대(results):
|
||||
"""390-3 — 마우스 Ctrl+휠 한 칸 = 5° · 트랙패드 벌림 · 맨 휠 = 확대 · 추천 지도(rotateWheel 없음) = 확대."""
|
||||
w = results["wheel"]
|
||||
assert w["ctrlDown"] == {"deg": pytest.approx(5), "scale": 1, "changes": 1}
|
||||
assert w["ctrlUp"]["deg"] == pytest.approx(-5) and w["ctrlUp"]["scale"] == 1
|
||||
for case in ("trackpad", "plain", "recommend"):
|
||||
assert w[case]["deg"] == 0 and w[case]["changes"] == 0, case
|
||||
assert w[case]["scale"] != 1, case
|
||||
|
||||
@@ -26,6 +26,11 @@ BURGER = (
|
||||
" return [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height),"
|
||||
" Math.round(r.right)]; })()"
|
||||
)
|
||||
TITLE_ROW_CY = (
|
||||
"(() => { const r = document.querySelector("
|
||||
"'.ui-workflow-overlay__panel--title > .ui-workflow-overlay__header').getBoundingClientRect();"
|
||||
" return Math.round(r.top + r.height / 2); })()"
|
||||
)
|
||||
PROGRESS_HIDDEN = (
|
||||
"!document.querySelector('.ui-workflow-overlay__panel--progress').getClientRects().length"
|
||||
)
|
||||
@@ -135,6 +140,8 @@ def test_drawer_holds_steps_and_header_folds():
|
||||
assert desk.evaluate(OUTLET_TOP) == 0
|
||||
assert desk.evaluate(BURGER)[:4] == [8, 8, 36, 36]
|
||||
assert desk.evaluate(PROGRESS_HIDDEN)
|
||||
# 389-1: ☰ 중심 = 좌측 패널 제목 줄 중심
|
||||
assert desk.evaluate(TITLE_ROW_CY) == 26
|
||||
head_nav = desk.evaluate(LABELS, ".app-nav button")
|
||||
head_actions = desk.evaluate(LABELS, ".app-actions button")
|
||||
assert head_nav and head_actions
|
||||
@@ -170,7 +177,22 @@ def test_352_4_burger_in_panel_title_row_text():
|
||||
# 자리(8,8 · 36)는 그대로 — 접으면 아래 기본 규칙(떠 있는 단추)으로 돌아감
|
||||
base = shell.split("html.is-bm-route .app-burger {", 1)[1].split("}", 1)[0]
|
||||
assert "position: fixed;" in base and "left: var(--spacing-8);" in base
|
||||
assert "html.is-bm-route .app-drawer__brand .app-brand { align-self: flex-end; }" in shell
|
||||
# 389-1: 마우스 제목 줄 높이 = 8×2 + ☰ 높이(36) = 52 → 중심 26 = ☰ 중심
|
||||
overlay_css = (root / "ui_template/ui_template_overlay.css").read_text(encoding="utf-8")
|
||||
title_row = overlay_css.split(
|
||||
"html.is-bm-route .ui-workflow-overlay__panel--title > .ui-workflow-overlay__header {", 1
|
||||
)[1].split("}", 1)[0]
|
||||
assert "min-height: calc(var(--spacing-8) * 2 + var(--app-burger-w));" in title_row
|
||||
# 389-2: 서랍 8/12 체계 — 묶음 사이 12 + 선 + 12 · 로고 줄 = ☰ 높이 가운데
|
||||
assert (
|
||||
"gap: var(--spacing-12);" in shell.split(".app-header.is-drawer-open .app-drawer {", 1)[1]
|
||||
)
|
||||
assert "padding-top: var(--spacing-12);" in shell.split(".app-drawer__group + ", 1)[1]
|
||||
assert (
|
||||
"min-height: var(--app-burger-w);"
|
||||
in shell.split("html.is-bm-route .app-drawer__brand {", 1)[1]
|
||||
)
|
||||
assert "align-self: flex-end" not in shell
|
||||
# 378-1: 프로젝트 화면 서랍 오른쪽 가장자리 = 강조색 2 px
|
||||
assert "border-right: 2px solid var(--color-accent);" in shell
|
||||
overlay = (root / "ui_template/ui_template_overlay.css").read_text(encoding="utf-8")
|
||||
|
||||
@@ -329,10 +329,10 @@ def test_구조물_조합_제목_줄_새_항목():
|
||||
|
||||
|
||||
def test_들어오면_두_컨테이너_펼침_끌기_뒤_강조_지움():
|
||||
"""31-5 — 좌측 「구조물 조합」 펼친 채(일위대가는 그 하위 · 39-2) · 끌어 놓은 뒤 강조(나무 칸
|
||||
자신 = 맨 위 자리 포함)를 모두 지움 · 칸 밖으로 나가면 지움(칸 안 자식 위로는 그대로)."""
|
||||
"""31-5 — 끌어 놓은 뒤 강조(나무 칸 자신 = 맨 위 자리 포함)를 모두 지움 · 칸 밖으로 나가면 지움
|
||||
(칸 안 자식 위로는 그대로) · 389-3 좌측 「구조물 집계 양식」 도 처음엔 닫힘."""
|
||||
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
|
||||
assert 'summary.classList.remove("is-collapsed");' in side
|
||||
assert 'summary.classList.remove("is-collapsed");' not in side
|
||||
drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts")
|
||||
mark = drag.split("const dropMark = ")[1].split("};")[0]
|
||||
assert 'body.classList.remove("is-drop");' in mark
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
* 스타일은 전역 `ui_template_theme.css`에 있어 어떤 페이지에서도 클래스만 붙이면 동작한다.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 좌측 패널 컨테이너 공용 카드(269) — 접는 제목 + 공용 양식(`ui-panel-form`) · 내용은 카드에 바로 붙임. */
|
||||
export function createPanelCard(title: string, collapsed = false): HTMLElement {
|
||||
/** 좌측 패널 컨테이너 공용 카드(269) — 접는 제목 + 공용 양식(`ui-panel-form`) · 내용은 카드에 바로 붙임 · 기본 닫힘(389-3). */
|
||||
export function createPanelCard(title: string, collapsed = true): HTMLElement {
|
||||
const section = document.createElement("section");
|
||||
section.className = `ui-collapsible ui-sidebar-section ui-sidebar-card ui-panel-form${collapsed ? " is-collapsed" : ""}`;
|
||||
const heading = document.createElement("h3");
|
||||
|
||||
@@ -557,6 +557,7 @@ html:is([data-size="compact"], [data-size="medium"])
|
||||
/* ☰ 들여쓰기(7-2) — 왼쪽 위에 뜬 ☰ 자리만큼 좌측 패널 제목 줄을 비킴 · 손가락 ☰(높이 44)는 제목 줄도 그만큼 높임 */
|
||||
html.is-bm-route .ui-workflow-overlay__panel--title > .ui-workflow-overlay__header {
|
||||
padding-left: max(var(--spacing-16), var(--app-burger-inset));
|
||||
min-height: calc(var(--spacing-8) * 2 + var(--app-burger-w));
|
||||
}
|
||||
|
||||
html.is-bm-route[data-touch="on"]
|
||||
|
||||
@@ -49,8 +49,8 @@ export interface SidePanelsOpts {
|
||||
|
||||
export interface SidePanels {
|
||||
root: HTMLElement;
|
||||
/** 열 패널(null = 모두 접음) — 기억함 */
|
||||
open(id: string | null): void;
|
||||
/** 열 패널(null = 모두 접음) — 기억함 · remember 거짓 = 이번 화면만(자동 열림) */
|
||||
open(id: string | null, remember?: boolean): void;
|
||||
current(): string | null;
|
||||
width(): number;
|
||||
}
|
||||
@@ -82,7 +82,8 @@ export function createSidePanels(opts: SidePanelsOpts): SidePanels {
|
||||
? (ids[0] ?? null)
|
||||
: opts.defaultOpen;
|
||||
let width = clampSideWidth(pref.width ?? opts.defaultWidth ?? 420, min, window.innerWidth);
|
||||
const save = (): void => writeByKey(key, JSON.stringify({ open: now, width }));
|
||||
let kept = now; // 기억할 열림 — 자동 열림은 안 셈
|
||||
const save = (): void => writeByKey(key, JSON.stringify({ open: kept, width }));
|
||||
|
||||
const root = el("div", { className: "ui-side-panels" });
|
||||
root.style.setProperty("--ui-side-panels-width", `${width}px`);
|
||||
@@ -159,12 +160,15 @@ export function createSidePanels(opts: SidePanelsOpts): SidePanels {
|
||||
root.classList.toggle("is-collapsed", now === null);
|
||||
};
|
||||
|
||||
function open(id: string | null): void {
|
||||
function open(id: string | null, remember = true): void {
|
||||
const next = id !== null && ids.includes(id) ? id : null;
|
||||
const was = now;
|
||||
now = next;
|
||||
draw();
|
||||
save();
|
||||
if (remember) {
|
||||
kept = next;
|
||||
save();
|
||||
}
|
||||
if (next !== was) opts.onChange?.(next);
|
||||
}
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
* 배경이 위성사진이라 라이트/다크 공통으로 같은 색을 쓴다.
|
||||
* ------------------------------------------------------------------------ */
|
||||
--map-route: #f97316; /* 계획선 (B04 지도·B05 배수유역도 공통) */
|
||||
--map-route-locked: #db2777; /* 계획노선 편집 — 고정한 선 · 호 · 점 (주황 · 초록 · 연보라 · 하늘색과 구별) */
|
||||
--map-route-locked: #ca8a04; /* 계획노선 편집 — 고정한 선 · 호 · 점 황금 (평면의 주황 · 절토 붉음 · 성토 청록 · 하늘색과 구별) */
|
||||
--map-cadastral: #f97316;
|
||||
--map-sigungu: #7c3aed;
|
||||
--map-eupmyeondong: #22c55e;
|
||||
@@ -333,7 +333,7 @@
|
||||
--color-warning: #e0b64a;
|
||||
--color-danger: #e8695a;
|
||||
--panel-handle-border: #e0b64a;
|
||||
--map-route-locked: #f472b6;
|
||||
--map-route-locked: #facc15;
|
||||
|
||||
--shadow-sm: rgba(0, 0, 0, 0.4) 0px 2px 4px 0px, rgba(0, 0, 0, 0.2) 0px 0px 0px 1px;
|
||||
--shadow-lg: rgba(0, 0, 0, 0.5) 0px 32px 24px -12px, rgba(0, 0, 0, 0.3) 0px 3px 3px 0px;
|
||||
@@ -374,7 +374,7 @@
|
||||
--color-success: #3fbb6f;
|
||||
--color-danger: #e8695a;
|
||||
--panel-handle-border: #e0b64a;
|
||||
--map-route-locked: #f472b6;
|
||||
--map-route-locked: #facc15;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user