Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -23,16 +23,29 @@ import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_type
|
||||
/** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */
|
||||
export type AnalysisTerrain = "las" | "sheet";
|
||||
|
||||
/** 고른 분석 지형 — 화면 상태 보관소 pref 통(노선 편집 고정과 같은 자리). 없으면 `fallback`
|
||||
* (모달이 열 때 설계 지형으로 채움). [확인] 때 이 값이 설계 지형이 됨(PLAN 133-2). */
|
||||
/** 편집기가 열 때 알리는 지형 사정(352-3) — 저장하지 않음. 고를 수 있나(라이다 · 수치지형도 둘) · 설계 지형. */
|
||||
const terrainContext = new Map<string, { choosable: boolean; design: AnalysisTerrain }>();
|
||||
|
||||
export const setTerrainContext = (
|
||||
projectId: string,
|
||||
choosable: boolean,
|
||||
design: AnalysisTerrain,
|
||||
): void => void terrainContext.set(projectId, { choosable, design });
|
||||
|
||||
/** 지금 쓰는 분석 지형 — 하나뿐인 프로젝트는 그것 · 둘이면 사용자가 고른 값(화면 상태 보관소 pref 통 ·
|
||||
* 노선 편집 고정과 같은 자리), 고른 적 없으면 데이터대로(= 설계 지형 · 라이다가 있으면 라이다).
|
||||
* 고른 값은 [저장] 때 설계 지형이 됨(PLAN 133-2). */
|
||||
export const analysisTerrain = (
|
||||
projectId: string,
|
||||
fallback: AnalysisTerrain = "las",
|
||||
): AnalysisTerrain => {
|
||||
const context = terrainContext.get(projectId);
|
||||
if (context && !context.choosable) return context.design;
|
||||
const saved = readState<string>("routeedit-terrain", projectId);
|
||||
return saved === "sheet" || saved === "las" ? saved : fallback;
|
||||
return saved === "sheet" || saved === "las" ? saved : (context?.design ?? fallback);
|
||||
};
|
||||
|
||||
/** 사용자가 고를 때만 기억 — 열 때 기본값은 쓰지 않음. */
|
||||
export const setAnalysisTerrain = (projectId: string, terrain: AnalysisTerrain): void =>
|
||||
writeState("routeedit-terrain", terrain, projectId);
|
||||
|
||||
|
||||
@@ -10,12 +10,11 @@
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import { profilePanelMaxHeight } from "./B05_Profile_UI_Profile_Heights";
|
||||
import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
|
||||
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
|
||||
import { viewOf } from "../common_util/common_util_view_window";
|
||||
|
||||
const MIN_CHART_HEIGHT = 100;
|
||||
/** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게.
|
||||
/** 접힌 테이블 손잡이가 앉을 본문 아래 전용 띠 — 곡선 행 클릭을 가리지 않게.
|
||||
* 구조물 알약 레인이 그 위에 붙은 뒤로는 사이가 떠 보여 절반으로 줄였다
|
||||
* (2026-08-17 사용자 지시 2). */
|
||||
const MASSHAUL_HANDLE_GUTTER_PX = 13;
|
||||
@@ -93,7 +92,7 @@ export function createElevationHold(
|
||||
};
|
||||
}
|
||||
|
||||
/** 겹침 판(유토곡선 · 표) 중 끌기가 보는 것만. */
|
||||
/** 겹침 판(표) 중 끌기가 보는 것만. */
|
||||
interface SubPanel {
|
||||
overlay: HTMLElement;
|
||||
commitHeight: (height: number) => void;
|
||||
@@ -102,7 +101,8 @@ interface SubPanel {
|
||||
export interface ProfileDragContext {
|
||||
root: HTMLElement;
|
||||
body: HTMLElement;
|
||||
massHaul: SubPanel;
|
||||
/** 유토곡선 판 — 그 리사이저 끌기 출처만 본다(자기 판이라 높이 나눔에 안 낌 · 354-1). */
|
||||
massHaul: { overlay: HTMLElement };
|
||||
tableOverlay: SubPanel & { isOpen: () => boolean };
|
||||
/** 손을 뗀 뒤 한 번 다시 세운다. */
|
||||
draw: () => void;
|
||||
@@ -112,7 +112,7 @@ export interface ProfileDrag {
|
||||
resizerRoot: HTMLElement;
|
||||
isMainDragging: () => boolean;
|
||||
isSubDragging: () => boolean;
|
||||
mainDragRef: () => { chart: number; mass: number; table: number } | null;
|
||||
mainDragRef: () => { chart: number; table: number } | null;
|
||||
isMainDragCooldown: () => boolean;
|
||||
clearMainDragCooldown: () => void;
|
||||
/** 전체 재구성을 120ms 뒤로 미룬다(연속 호출은 마지막 한 번만). */
|
||||
@@ -133,23 +133,21 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
|
||||
};
|
||||
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
|
||||
// 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다.
|
||||
// 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2).
|
||||
const massDocked = (): boolean => !massHaul.overlay.hidden && root.contains(massHaul.overlay);
|
||||
const heightResizer = createPanelResizer({
|
||||
axis: "vertical",
|
||||
target: root,
|
||||
cssVar: "--b05-profile-height",
|
||||
direction: -1,
|
||||
// 동적 하한: 종단 최소 + 열린 서브패널들의 최소 높이 합(2026-08-05 사용자 확정).
|
||||
// 동적 하한: 종단 최소 + 열린 표 최소 높이(2026-08-05 사용자 확정) — 유토곡선 판 높이는
|
||||
// chrome(뿌리 − 본문)에 이미 들어 있다(354-1).
|
||||
// 패널을 아래로 끌면 종단이 먼저 줄고 → 서브패널들이 같이 줄고(draw의 캐스케이드) →
|
||||
// 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단).
|
||||
min: () => {
|
||||
const chrome = Math.max(0, root.offsetHeight - body.clientHeight);
|
||||
const massMin = massDocked() ? MASSHAUL_MIN_HEIGHT : 0;
|
||||
const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0;
|
||||
return Math.max(
|
||||
MIN_PANEL_HEIGHT,
|
||||
chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin,
|
||||
chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + tableMin,
|
||||
);
|
||||
},
|
||||
// 상한 = 가운데 칸 높이의 90%(위 칸 3D · 횡단 카드가 다 가려지지 않게) — Heights 와 한 식
|
||||
@@ -166,19 +164,14 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
|
||||
* 플래그가 이미 풀린 채 돌아, 드래그 중 금지했던 자동 확대가 손 떼는 순간 발동해
|
||||
* 포인터가 정한 높이를 되돌렸다(2026-08-06 분석 원인 4). draw()가 끝나며 푼다. */
|
||||
let mainDragCooldown = false;
|
||||
/** 메인 드래그 시작 시점의 3영역(종단·유토곡선·테이블) 실제 높이 — 비례 연동 기준.
|
||||
/** 메인 드래그 시작 시점의 두 영역(종단·테이블) 실제 높이 — 비례 연동 기준.
|
||||
* 드래그 중 이 비율대로 같이 늘리고 줄인다(2026-08-06 사용자 확정). */
|
||||
let mainDragRef: { chart: number; mass: number; table: number } | null = null;
|
||||
let mainDragRef: { chart: number; table: number } | null = null;
|
||||
heightResizer.root.addEventListener("pointerdown", () => {
|
||||
mainPanelDragging = true;
|
||||
const massH = massDocked() ? massHaul.overlay.offsetHeight : 0;
|
||||
const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0;
|
||||
mainDragRef = {
|
||||
chart: Math.max(
|
||||
MIN_CHART_HEIGHT,
|
||||
body.clientHeight - massH - tableH - MASSHAUL_HANDLE_GUTTER_PX,
|
||||
),
|
||||
mass: massH,
|
||||
chart: Math.max(MIN_CHART_HEIGHT, body.clientHeight - tableH - MASSHAUL_HANDLE_GUTTER_PX),
|
||||
table: tableH,
|
||||
};
|
||||
});
|
||||
@@ -193,9 +186,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
|
||||
// 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정
|
||||
// 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전
|
||||
// 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다).
|
||||
if (massDocked()) massHaul.commitHeight(massHaul.overlay.offsetHeight);
|
||||
if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight);
|
||||
massHaul.overlay.style.height = "";
|
||||
tableOverlay.overlay.style.height = "";
|
||||
}
|
||||
mainDragRef = null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Heights.ts
|
||||
* 종단 패널의 높이 배분 — 그래프·유토곡선·테이블이 한 패널 높이를 나눠 갖는 규칙.
|
||||
* 종단 패널의 높이 배분 — 그래프·테이블이 한 본문 높이를 나눠 갖는 규칙.
|
||||
* 유토곡선은 종단 열 아래 자기 판이라 본문 높이에서 이미 빠져 있다 — 나눔에 안 낀다(354-1).
|
||||
*
|
||||
* 패널 본체(B05_Profile_UI_Profile_Panel)가 700줄 한계에 닿아 분리했다.
|
||||
* 판정 기준은 **저장 높이**(리사이저 변수)이며 화면 높이가 아니다 — 화면 높이로 재면
|
||||
@@ -8,14 +9,13 @@
|
||||
* 드래그 플래그·상세 데이터 유무는 본체가 계속 들고 있고 여기서는 접근자로만 읽는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
|
||||
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
|
||||
import { STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks";
|
||||
import { frameOf } from "../common_util/common_util_view_window";
|
||||
|
||||
/** 그래프가 이보다 낮아지면 종단선을 읽을 수 없다 — 어떤 배분에서도 지키는 하한(px). */
|
||||
const MIN_CHART_HEIGHT = 100;
|
||||
/** 접힌 유토곡선 손잡이가 차지하는 바닥 여백(px). */
|
||||
/** 접힌 테이블 손잡이가 차지하는 바닥 여백(px). */
|
||||
const MASSHAUL_HANDLE_GUTTER_PX = 13;
|
||||
/** 패널 자동 확대 상한 — 메인 리사이저 max와 같은 식이어야 한다(부모 높이 비율). */
|
||||
const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
||||
@@ -32,21 +32,15 @@ export interface HeightCascadeDeps {
|
||||
root: HTMLElement;
|
||||
/** 본문(그래프+서브패널이 들어가는 스크롤 영역). */
|
||||
body: HTMLElement;
|
||||
massHaul: {
|
||||
overlay: HTMLElement;
|
||||
desiredHeight: () => number;
|
||||
syncHandle: () => void;
|
||||
};
|
||||
tableOverlay: {
|
||||
overlay: HTMLElement;
|
||||
isOpen: () => boolean;
|
||||
desiredHeight: () => number;
|
||||
setBottomOffset: (value: number) => void;
|
||||
};
|
||||
isMainDragging: () => boolean;
|
||||
isSubDragging: () => boolean;
|
||||
/** 메인 드래그 시작 시점의 3영역 비율(없으면 null). */
|
||||
mainDragRef: () => { chart: number; mass: number; table: number } | null;
|
||||
/** 메인 드래그 시작 시점의 두 영역 비율(없으면 null). */
|
||||
mainDragRef: () => { chart: number; table: number } | null;
|
||||
/** 손 뗀 직후 첫 재구성까지는 자동 확대를 막는다. */
|
||||
isMainDragCooldown: () => boolean;
|
||||
/** 상세 데이터가 아직 없으면 경량 동기화를 건너뛴다. */
|
||||
@@ -67,21 +61,17 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
|
||||
// 구조물 알약 레인이 그래프 아래 한 줄을 차지한다 — 그만큼 먼저 떼어 놓고 나눈다
|
||||
// (2026-08-17 사용자 지시 6: 그래프가 밀려도 되지만 여유는 최소).
|
||||
const available = Math.max(120, deps.body.clientHeight - STRUCTURE_LANE_HEIGHT_PX);
|
||||
// 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2).
|
||||
const massOpen = !deps.massHaul.overlay.hidden && deps.root.contains(deps.massHaul.overlay);
|
||||
const tableOpen = deps.tableOverlay.isOpen();
|
||||
// 판정 기준은 항상 **저장 높이**(리사이저 변수·기본값)다 — 임시 축소가 반영된 화면
|
||||
// 높이(offsetHeight)로 재면 "줄임 → 충분해 보임 → 풀림 → 부족 → 다시 줄임"이
|
||||
// 호출마다 번갈아 도는 진동이 되고, 풀리는 호출의 deficit 처리가 메인 패널을
|
||||
// 멋대로 키운다(2026-08-06 분석: 튐·자동 확대의 핵심 원인). 저장 높이 기준이면
|
||||
// 몇 번을 호출해도 같은 답이 나온다(멱등).
|
||||
const massDesired = massOpen ? deps.massHaul.desiredHeight() : 0;
|
||||
const tableDesired = tableOpen ? deps.tableOverlay.desiredHeight() : 0;
|
||||
let massHeight = massDesired;
|
||||
let tableOverlayHeight = tableDesired;
|
||||
const budget = Math.max(0, available - MIN_CHART_HEIGHT - MASSHAUL_HANDLE_GUTTER_PX);
|
||||
// ── 메인 드래그 중: 3영역 비례 연동(2026-08-06 사용자 확정) ─────────────
|
||||
// 드래그 시작 시점 비율(deps.mainDragRef())대로 종단·유토곡선·테이블을 같이 늘리고
|
||||
// ── 메인 드래그 중: 비례 연동(2026-08-06 사용자 확정) ─────────────
|
||||
// 드래그 시작 시점 비율(deps.mainDragRef())대로 종단·테이블을 같이 늘리고
|
||||
// 줄인다. 최소에 닿은 영역은 거기서 멈추고 남은 영역끼리 다시 비례 배분한다.
|
||||
// 손을 떼면 clearDragFlags가 이 결과를 저장 높이로 확정한다.
|
||||
const dragRef = deps.mainDragRef();
|
||||
@@ -89,15 +79,12 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
|
||||
const content = Math.max(0, available - MASSHAUL_HANDLE_GUTTER_PX);
|
||||
const items = [
|
||||
{ key: "chart", ref: Math.max(1, dragRef.chart), min: MIN_CHART_HEIGHT },
|
||||
...(massOpen
|
||||
? [{ key: "mass", ref: Math.max(1, dragRef.mass), min: MASSHAUL_MIN_HEIGHT }]
|
||||
: []),
|
||||
...(tableOpen
|
||||
? [{ key: "table", ref: Math.max(1, dragRef.table), min: TABLE_OVERLAY_MIN_HEIGHT }]
|
||||
: []),
|
||||
];
|
||||
// 고정 → 재배분 반복: 축소 배율로 최소를 뚫는 항목을 최소에 고정하고, 남은
|
||||
// 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 3개라 최대 3회에 끝난다.
|
||||
// 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 2개라 최대 2회에 끝난다.
|
||||
const out: Record<string, number> = {};
|
||||
let pool = content;
|
||||
let active = items;
|
||||
@@ -116,50 +103,34 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
|
||||
active = active.filter((item) => !pinned.includes(item));
|
||||
}
|
||||
// 서브패널 상한(개별 리사이저 max와 같은 비율)은 안전상 유지 — 넘치면 종단이 흡수.
|
||||
massHeight = massOpen ? Math.min(out.mass ?? 0, Math.round(available * 0.8)) : 0;
|
||||
tableOverlayHeight = tableOpen ? Math.min(out.table ?? 0, Math.round(available * 0.75)) : 0;
|
||||
if (massOpen) deps.massHaul.overlay.style.height = `${massHeight}px`;
|
||||
if (tableOpen) deps.tableOverlay.overlay.style.height = `${tableOverlayHeight}px`;
|
||||
// 손잡이는 바뀐 인라인 높이의 위 경계를 즉시 따라간다.
|
||||
deps.massHaul.syncHandle();
|
||||
deps.tableOverlay.setBottomOffset(massHeight);
|
||||
return {
|
||||
chartHeight: Math.max(MIN_CHART_HEIGHT, content - massHeight - tableOverlayHeight),
|
||||
};
|
||||
return { chartHeight: Math.max(MIN_CHART_HEIGHT, content - tableOverlayHeight) };
|
||||
}
|
||||
if (deps.isSubDragging()) {
|
||||
// 서브패널 리사이저를 끄는 중이면 그 높이가 **사용자 의도**다 — 임시 축소(인라인)를
|
||||
// 걷어 변수(드래그 값)가 그대로 보이게 하고, 자리가 모자라면 아래 deficit 처리로
|
||||
// 메인 패널을 키운다. 변수는 드래그마다 갱신되므로 저장 높이 = 드래그 값이다.
|
||||
deps.massHaul.overlay.style.height = "";
|
||||
deps.tableOverlay.overlay.style.height = "";
|
||||
} else if (massDesired + tableDesired > budget) {
|
||||
// ② 같이 줄이기 — 줄일 수 있는 여유분에 비례해 축소, 각자 최소 높이 하한.
|
||||
} else if (tableDesired > budget) {
|
||||
// ② 줄이기 — 줄일 수 있는 여유분만큼 축소, 최소 높이 하한.
|
||||
// 자리가 다시 늘면 ratio가 매끄럽게 0으로 줄어 저장 높이로 연속 복귀한다.
|
||||
const massMin = massOpen ? MASSHAUL_MIN_HEIGHT : 0;
|
||||
const tableMin = tableOpen ? TABLE_OVERLAY_MIN_HEIGHT : 0;
|
||||
const shrinkable = massDesired - massMin + (tableDesired - tableMin);
|
||||
const over = massDesired + tableDesired - budget;
|
||||
const shrinkable = tableDesired - tableMin;
|
||||
const over = tableDesired - budget;
|
||||
const ratio = shrinkable > 0 ? Math.min(1, over / shrinkable) : 1;
|
||||
massHeight = Math.round(massDesired - (massDesired - massMin) * ratio);
|
||||
tableOverlayHeight = Math.round(tableDesired - (tableDesired - tableMin) * ratio);
|
||||
// 임시 축소는 인라인 높이로만 — 리사이저 저장값(--변수·세션)은 건드리지 않아
|
||||
// 패널을 다시 키우면 원래 높이로 돌아온다.
|
||||
if (massOpen) deps.massHaul.overlay.style.height = `${massHeight}px`;
|
||||
if (tableOpen) deps.tableOverlay.overlay.style.height = `${tableOverlayHeight}px`;
|
||||
} else {
|
||||
// 공간 충분 — 임시 축소 해제(저장 높이로 복귀).
|
||||
deps.massHaul.overlay.style.height = "";
|
||||
deps.tableOverlay.overlay.style.height = "";
|
||||
}
|
||||
// 접힌 유토곡선 손잡이는 바닥 고정(순서상 테이블 아래에서 나옴), 접힌 테이블 손잡이는
|
||||
// 열린 유토곡선 위 경계(TableOverlay.syncHandlePosition).
|
||||
deps.tableOverlay.setBottomOffset(massHeight);
|
||||
// ③' 최소까지 줄여도 모자라면(서브패널 펼침·서브패널 드래그로 자리가 부족한 경우)
|
||||
// 메인 패널을 키운다. 단 **메인 패널을 끄는 중과 손 뗀 직후 첫 재구성까지**는 절대
|
||||
// 안 된다 — 포인터가 정한 높이를 되돌려 서로 밀고 당기는 진동이 생긴다.
|
||||
const deficit =
|
||||
MIN_CHART_HEIGHT + massHeight + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available;
|
||||
const deficit = MIN_CHART_HEIGHT + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available;
|
||||
if (deficit > 1 && allowGrow && !deps.isMainDragging() && !deps.isMainDragCooldown()) {
|
||||
// 상한은 메인 리사이저 max와 **같은 식**이어야 한다 — 예전 window 92% 상한은
|
||||
// 리사이저 상한(부모 90%)보다 높아, 자동 확대 직후 손잡이를 잡는 순간 낮은
|
||||
@@ -174,7 +145,7 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
|
||||
return {
|
||||
chartHeight: Math.max(
|
||||
MIN_CHART_HEIGHT,
|
||||
available - massHeight - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX,
|
||||
available - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@ export function setPageHistory(history: ProfileHistory): void {
|
||||
active = history;
|
||||
}
|
||||
|
||||
/** 되돌리기 · 다시하기가 세션을 되쓰고 화면을 되살리는 중인가 — 그 사이 바뀐 노선은 새 조작이 아님(356-4). */
|
||||
let replaying = false;
|
||||
export const pageReplaying = (): boolean => replaying;
|
||||
|
||||
export type PageHistoryEvent = "undo" | "redo";
|
||||
interface PageHistoryHooks {
|
||||
/** 세션을 되쓴 뒤 — 자기 화면 값을 다시 읽음(사진첩이 막고 있는 동안이라 쌓이지 않음). */
|
||||
@@ -193,7 +197,7 @@ export function createProfileHistory(onRestore: () => void, limit = 100): Profil
|
||||
if (index < 0 || index >= stack.length) return false;
|
||||
const kind: PageHistoryEvent = index < cursor ? "undo" : "redo";
|
||||
cursor = index;
|
||||
restoring = true;
|
||||
restoring = replaying = true;
|
||||
restoreSnapshot(stack[cursor]);
|
||||
try {
|
||||
onRestore();
|
||||
@@ -201,7 +205,7 @@ export function createProfileHistory(onRestore: () => void, limit = 100): Profil
|
||||
// 되살리며 다시 쓴 같은 값(정리된 꼴)을 이 걸음으로 — 다음 찍기가 헛걸음을 안 만듦.
|
||||
stack[cursor] = takeSnapshot(stack[cursor]);
|
||||
} finally {
|
||||
restoring = false;
|
||||
restoring = replaying = false;
|
||||
}
|
||||
changed();
|
||||
return true;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_MassHaul.ts
|
||||
* B05 유토곡선 — 종단면 패널 바닥에 붙는 **오버레이 서브패널**.
|
||||
* B05 유토곡선 — 종단 열 아래 **자기 판**(354-1 · 배수유역도처럼 손잡이 · 높이 · 접기 따로).
|
||||
*
|
||||
* ── B06과 같은 그림이어야 한다 (2026-08-03 사용자 확정) ──────────────
|
||||
* 이 프로그램은 파일 입력 시 기본 설정값으로 B06 몫까지 미리 계산해 둔다 — 종횡단 상세
|
||||
* 조회(`fetchSectionDetail`)가 지정 설계 없는 측점에 기본 설계를 즉석 계산해 얹으므로
|
||||
* (`_attach_default_designs`), B05 진입 시점에 이미 **횡단 단면적 기반 정식 곡선**을 그릴
|
||||
* 수 있다. 그래서 B06과 똑같이 `computeMassHaulSeries`(횡단 기준 + 종단 개략 비교)를 쓰고,
|
||||
* 수 있다. 그래서 B06과 똑같이 `computeMassHaulSeries`(횡단 기준만 · 종단 기준 숨김 354-2)를 쓰고,
|
||||
* 범례·토량 분배·balloon·요약도 전부 공용 모듈 그대로 쓴다.
|
||||
*
|
||||
* 표시 토글 sessionStorage 키도 B06과 **공유**한다 — 두 화면은 같은 그림의 두 창이라
|
||||
@@ -14,12 +14,11 @@
|
||||
* 같은 영구저장소(`longitudinal_sections.data.mass_haul`)를 읽으므로, B06에서 횡단을
|
||||
* 고쳐 임시저장/확정하면 B05는 재조회만으로 같은 곡선을 받는다.
|
||||
*
|
||||
* ── 배치 (2026-08-04 사용자 확정: 오버레이 전환) ─────────────────────
|
||||
* 예전에는 펼치면 종단 그래프와 세로 1:1 분할 + 도면 테이블 숨김이었다. 이제는 종단도·
|
||||
* 테이블 배치를 **건드리지 않고**, 패널 바닥에 붙는 오버레이가 그 위를 덮는다. 높이는
|
||||
* 오버레이 위 경계의 리사이저(메인 하단 패널과 같은 `createPanelResizer`)로 조절하고
|
||||
* 세션 동안 유지한다. 곡선 SVG는 오버레이 안 자체 가로 스크롤러에 들어가며, 종단
|
||||
* 스크롤러와 scrollLeft를 양방향 동기화해 측점 세로선이 어긋나지 않게 한다.
|
||||
* ── 배치 (354-1 · 요구 235-7 「유토곡선도 분리」) ─────────────────────
|
||||
* 종단 열(그래프 · 표) 아래 칸에 따로 선다 — 종단도 · 표 위를 덮지 않고 높이 나눔 · 표 쌓기에도
|
||||
* 안 낀다. 높이는 판 위 경계 리사이저(`createPanelResizer`)로 조절하고 세션 동안 유지한다.
|
||||
* 머리줄([창 분할] · 접기 손잡이)은 접어도 남는다. 곡선 SVG는 판 안 자체 가로 스크롤러에
|
||||
* 들어가며, 종단 스크롤러와 scrollLeft를 양방향 동기화해 측점 세로선이 어긋나지 않게 한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
@@ -56,7 +55,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import "@util/common_util_mass_haul.css";
|
||||
import "./B05_Profile_UI_Style_MassHaul.css";
|
||||
import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel";
|
||||
import { attachWheelHorizontalScroll, linkScrollLeft } from "./B05_Profile_UI_Profile_Wheel";
|
||||
import { frameOf } from "../common_util/common_util_view_window";
|
||||
|
||||
/** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */
|
||||
@@ -65,12 +64,10 @@ import { frameOf } from "../common_util/common_util_view_window";
|
||||
const OVERLAY_HEIGHT_KEY = stateKey("masshaul-height") ?? "";
|
||||
/** 오버레이 높이 하한/기본값(px). 곡선 + 요약 막대가 읽히는 최소 크기다. */
|
||||
const OVERLAY_MIN_HEIGHT = 160;
|
||||
/** 유토곡선 오버레이 최소 높이 — 메인 패널 하한 계산(Profile_Panel)이 함께 쓴다. */
|
||||
export const MASSHAUL_MIN_HEIGHT = OVERLAY_MIN_HEIGHT;
|
||||
const OVERLAY_DEFAULT_HEIGHT = 280;
|
||||
/** 전체 맞춤(토량 분배) 보기의 우측 여백(px) — 마지막 점의 값 글자가 잘리지 않을 만큼만. */
|
||||
const MASS_FIT_PAD_RIGHT = 12;
|
||||
/** 오버레이가 패널 본문을 다 덮지 않게 남기는 상한 비율. */
|
||||
/** 판이 종단 열을 다 차지하지 않게 남기는 상한 비율. */
|
||||
const OVERLAY_MAX_RATIO = 0.8;
|
||||
/** 리사이저가 높이를 담는 CSS 변수 — CSS 기본값(280px)은 OVERLAY_DEFAULT_HEIGHT와 같아야 한다. */
|
||||
const HEIGHT_VAR = "--b05-masshaul-height";
|
||||
@@ -147,24 +144,18 @@ export interface RouteMassHaulDrawParams {
|
||||
}
|
||||
|
||||
export interface RouteMassHaulPanel {
|
||||
/** 종단면 패널 바닥에 붙는 2차 슬라이드 손잡이(펼치기/접기). */
|
||||
/** 판 머리줄의 접기 손잡이(펼치기/접기) — 판 안에 있다. */
|
||||
handle: HTMLElement;
|
||||
/** 오버레이 서브패널 — 호출한 쪽이 `position: relative`인 패널 본문 래퍼에 붙인다. */
|
||||
/** 유토곡선 판 — 호출한 쪽이 종단 열 아래(`b05-route-profile__column` 끝)에 붙인다. */
|
||||
overlay: HTMLElement;
|
||||
isOpen(): boolean;
|
||||
/** 저장된 오버레이 높이(px) — 리사이저 변수(세션)나 기본값. 임시 축소(인라인)는 무시한다.
|
||||
* Panel의 높이 캐스케이드가 호출마다 같은 판정을 내리는 기준(2026-08-06 진동 수정). */
|
||||
desiredHeight(): number;
|
||||
/** 저장 높이를 확정한다(리사이저와 같은 변수+세션 기록) — 메인 패널 비례 연동이
|
||||
* 드래그를 마친 높이를 앞으로의 기준으로 못 박는 데 쓴다(2026-08-06 사용자 확정). */
|
||||
commitHeight(px: number): void;
|
||||
/** 손잡이를 오버레이 위 경계로 다시 맞춘다 — 캐스케이드가 인라인 높이를 바꾼 직후 호출. */
|
||||
syncHandle(): void;
|
||||
setContext(next: RouteMassHaulContext | null): void;
|
||||
/** 종단 가로 스크롤러와 scrollLeft를 양방향 동기화한다(설치 1회). */
|
||||
attachScrollSync(main: HTMLElement): void;
|
||||
/** 오버레이 내용을 다시 그린다. 접혀 있으면 오버레이만 숨기고 끝낸다. */
|
||||
/** 판 내용을 다시 그린다. 접혀 있으면 머리줄만 남기고 끝낸다. */
|
||||
draw(params: RouteMassHaulDrawParams): void;
|
||||
/** 가로 스크롤로 보이는 구간만 바뀜 — 창을 따를 때만 · 한 틀 한 번 · 다시 셈 없음(354-3). */
|
||||
drawWindow(params: RouteMassHaulDrawParams): void;
|
||||
}
|
||||
|
||||
function readVisible(): Set<string> {
|
||||
@@ -173,8 +164,11 @@ function readVisible(): Set<string> {
|
||||
if (!raw) return new Set(DEFAULT_VISIBLE);
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
// 기준 키는 라디오라 항상 하나로 눌러 맞춘다(B06 읽기와 같은 규칙).
|
||||
// 종단 기준은 안 그림(354-2) — 저장된 범례가 종단이면 횡단으로 맞춘다.
|
||||
return Array.isArray(parsed)
|
||||
? normalizeVisibleBasis(new Set(parsed.map(String)))
|
||||
? normalizeVisibleBasis(
|
||||
new Set(parsed.map((key) => (key === "longitudinal" ? "cross" : String(key)))),
|
||||
)
|
||||
: new Set(DEFAULT_VISIBLE);
|
||||
} catch {
|
||||
return new Set(DEFAULT_VISIBLE);
|
||||
@@ -205,8 +199,12 @@ export function createRouteMassHaulPanel(
|
||||
handle.className = "b05-profile__masshaul-handle";
|
||||
handle.append(handleControl.root);
|
||||
handleControl.root.setAttribute("aria-label", "유토곡선 패널");
|
||||
// 머리줄 — 접어도 남아 [창 분할]([attachSubPanelPopouts] 가 맨 앞에 붙임) · 손잡이가 닿는다(354-1).
|
||||
const head = document.createElement("div");
|
||||
head.className = "b05-profile__masshaul-head";
|
||||
head.append(handle);
|
||||
|
||||
// 오버레이 뼈대 — 위 경계 리사이저 + 안내 문구 자리 + 가로 스크롤러 + 범례.
|
||||
// 판 뼈대 — 위 경계 리사이저 + 머리줄 + 안내 문구 자리 + 가로 스크롤러 + 범례.
|
||||
// (요약 막대는 2026-09-06 종단 상단줄로 올라갔다.)
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "b05-profile__masshaul-overlay";
|
||||
@@ -233,8 +231,7 @@ export function createRouteMassHaulPanel(
|
||||
max: () => (overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO,
|
||||
storageKey: OVERLAY_HEIGHT_KEY,
|
||||
onResize: () => {
|
||||
// 손잡이는 경계를 즉시 따라가고(가벼움), 전체 redraw는 프레임당 한 번만.
|
||||
syncHandlePosition();
|
||||
// 전체 redraw는 프레임당 한 번만.
|
||||
if (resizeRedrawPending) return;
|
||||
resizeRedrawPending = true;
|
||||
frameOf(overlay, () => {
|
||||
@@ -249,20 +246,14 @@ export function createRouteMassHaulPanel(
|
||||
pendingChip.className = "b05-profile__masshaul-pending";
|
||||
pendingChip.textContent = "다시 계산 중…";
|
||||
pendingChip.hidden = true;
|
||||
overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip);
|
||||
overlay.append(resizer.root, head, bar, scroll, legendLayer, pendingChip);
|
||||
|
||||
// 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시).
|
||||
// scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로.
|
||||
attachWheelHorizontalScroll(scroll);
|
||||
|
||||
/**
|
||||
* 손잡이를 오버레이 **위 경계**에 태운다(2026-08-04 사용자 지시 — 펼치면 같이 올라와
|
||||
* 테이블 영역을 침범해도 된다). 접혀 있으면 패널 바닥으로 돌아온다 — 유토곡선은 서브패널
|
||||
* 순서상 맨 아래라 테이블이 열려 있어도 버튼은 바닥에 남는다(2026-08-05 사용자 확정).
|
||||
*/
|
||||
function syncHandlePosition(): void {
|
||||
handle.style.bottom = open ? `${overlay.offsetHeight}px` : "0px";
|
||||
}
|
||||
/** 곡선을 그리는가 — 펼침이거나 새 창으로 뗌(뗀 창엔 접기가 없음 · 354-1). */
|
||||
const shown = (): boolean => open || overlay.classList.contains("is-popout");
|
||||
|
||||
function applyOpen(next: boolean): void {
|
||||
open = next;
|
||||
@@ -271,14 +262,14 @@ export function createRouteMassHaulPanel(
|
||||
// 툴팁은 setOpen이 일반 문구로 덮으므로 매번 유토곡선용으로 다시 밝힌다.
|
||||
handleControl.root.title = next ? "유토곡선 접기" : "유토곡선 펼치기";
|
||||
handle.classList.toggle("is-open", next);
|
||||
overlay.hidden = !next;
|
||||
syncHandlePosition();
|
||||
// 접으면 머리줄만 남는다(배수유역도처럼 손잡이는 늘 닿음)
|
||||
overlay.classList.toggle("is-collapsed", !next);
|
||||
onChanged();
|
||||
}
|
||||
handleControl.setOpen(open);
|
||||
handleControl.root.title = open ? "유토곡선 접기" : "유토곡선 펼치기";
|
||||
handle.classList.toggle("is-open", open);
|
||||
overlay.hidden = !open;
|
||||
overlay.classList.toggle("is-collapsed", !open);
|
||||
handleControl.root.addEventListener("click", () => applyOpen(!open));
|
||||
|
||||
function toggleSeries(key: string): void {
|
||||
@@ -288,32 +279,29 @@ export function createRouteMassHaulPanel(
|
||||
onChanged();
|
||||
}
|
||||
|
||||
// 종단 스크롤러와의 양방향 동기화 — 재진입 루프는 플래그로 끊는다.
|
||||
// 종단 스크롤러와의 양방향 동기화 — 메아리는 공용 도우미가 걸러 냄(354-3).
|
||||
// 토량 분배(전체 맞춤) 중에는 끊는다 — 유토곡선이 0 으로 붙으며 낸 scroll 이 종단 그래프 ·
|
||||
// 표까지 0 으로 되돌렸다(PLAN 79-1). 유토곡선만 스크롤이 없는 상태.
|
||||
let syncing = false;
|
||||
let fitted = false;
|
||||
function mirror(from: HTMLElement, to: HTMLElement): void {
|
||||
from.addEventListener("scroll", () => {
|
||||
if (syncing || fitted) return;
|
||||
syncing = true;
|
||||
to.scrollLeft = from.scrollLeft;
|
||||
syncing = false;
|
||||
});
|
||||
}
|
||||
let syncedMain: HTMLElement | null = null;
|
||||
let putScroll: ((to: HTMLElement, left: number) => void) | null = null;
|
||||
/** 종단과 맞물려 그린 곡선이 놓인 문서 — 처음 · 분배 맞춤 뒤 · 창 옮김 뒤면 종단 자리로 맞춘다. */
|
||||
let syncedIn: Document | null = null;
|
||||
function attachScrollSync(main: HTMLElement): void {
|
||||
if (syncedMain === main) return;
|
||||
syncedMain = main;
|
||||
mirror(main, scroll);
|
||||
mirror(scroll, main);
|
||||
putScroll = linkScrollLeft(main, scroll, () => fitted);
|
||||
}
|
||||
|
||||
/** 곡선을 한 번이라도 그렸는지 — 재계산 대기 중 화면을 비우지 않기 위한 표시. */
|
||||
let drawnOnce = false;
|
||||
/** 마지막으로 낸 계열 — 가로 스크롤(보이는 구간만 바뀜)은 다시 셈 없이 이걸로 그린다(354-3). */
|
||||
let lastSeries: MassHaulSeries[] | null = null;
|
||||
|
||||
function note(message: string): void {
|
||||
drawnOnce = false;
|
||||
lastSeries = null;
|
||||
syncedIn = null;
|
||||
pendingChip.hidden = true;
|
||||
bar.replaceChildren();
|
||||
scroll.replaceChildren();
|
||||
@@ -360,18 +348,33 @@ export function createRouteMassHaulPanel(
|
||||
);
|
||||
}
|
||||
|
||||
/** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. */
|
||||
function draw(params: RouteMassHaulDrawParams): void {
|
||||
pipelineWork("유토곡선", () => drawNow(params));
|
||||
/** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. `reuse` = 마지막 계열로(다시 셈 없음). */
|
||||
function draw(params: RouteMassHaulDrawParams, reuse = false): void {
|
||||
pipelineWork("유토곡선", () => drawNow(params, reuse));
|
||||
}
|
||||
|
||||
function drawNow(params: RouteMassHaulDrawParams): void {
|
||||
/** 가로 스크롤로 보이는 구간만 바뀜(354-3) — 곡선이 창을 따를 때만(열림 · 분배 맞춤 아님 · 붙음) ·
|
||||
* 한 틀 한 번 · 다시 셈 없이 세로 창만 다시 그림. 접힘 · 분배 · 새 창은 스크롤마다 할 일 없음. */
|
||||
let windowDirty = false;
|
||||
function drawWindow(params: RouteMassHaulDrawParams): void {
|
||||
if (!open || fitted || overlay.classList.contains("is-popout") || !lastSeries) return;
|
||||
lastParams = params;
|
||||
overlay.hidden = !open;
|
||||
syncHandlePosition();
|
||||
// 미끄러짐 틀이 이미 잡혀 있으면 그 틀이 이 값으로 그림
|
||||
if (windowDirty || windowState.frame) return;
|
||||
windowDirty = true;
|
||||
frameOf(overlay, () => {
|
||||
if (windowDirty && lastParams) draw(lastParams, true);
|
||||
});
|
||||
}
|
||||
|
||||
function drawNow(params: RouteMassHaulDrawParams, reuse: boolean): void {
|
||||
lastParams = params;
|
||||
windowDirty = false;
|
||||
overlay.classList.toggle("is-collapsed", !open);
|
||||
// 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다.
|
||||
// 펼친 경우에는 아래에서 두 기준을 내므로 그 결과로 총괄값을 낸다(계산 한 번).
|
||||
if (!open) {
|
||||
// 펼친 경우에는 아래에서 낸 계열로 총괄값을 낸다(계산 한 번).
|
||||
if (!shown()) {
|
||||
syncedIn = null;
|
||||
reportSummary(params);
|
||||
return;
|
||||
}
|
||||
@@ -400,23 +403,29 @@ export function createRouteMassHaulPanel(
|
||||
.map((chainage) => `${chainage.toFixed(2)} m`)
|
||||
.join(" · ")}${stale.length > 5 ? " …" : ""}`
|
||||
: "다시 계산 중…";
|
||||
// B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다.
|
||||
// B06과 같은 계산 — 횡단 기준(정식)만 낸다. 종단 기준은 안 셈 · 안 그림 · 범례 단추 없음
|
||||
// (354-2 · 계산 코드는 공용에 남김).
|
||||
// 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는
|
||||
// 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림).
|
||||
const series: MassHaulSeries[] = computeMassHaulSeries(
|
||||
params.longitudinal,
|
||||
params.crossSections,
|
||||
context.conversion,
|
||||
context.naturalSpoilMinSlope,
|
||||
);
|
||||
// 스크롤로 창만 바뀐 그리기(`reuse`)는 직전 계열 그대로 — 같은 입력의 두 번째 적분이었다(354-3).
|
||||
const series: MassHaulSeries[] =
|
||||
reuse && lastSeries
|
||||
? lastSeries
|
||||
: computeMassHaulSeries(
|
||||
params.longitudinal,
|
||||
params.crossSections,
|
||||
context.conversion,
|
||||
context.naturalSpoilMinSlope,
|
||||
["cross"],
|
||||
);
|
||||
// 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다.
|
||||
emitSummary(series);
|
||||
if (series !== lastSeries) emitSummary(series);
|
||||
lastSeries = series;
|
||||
if (!series.length) {
|
||||
note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
bar.replaceChildren();
|
||||
legendLayer.replaceChildren();
|
||||
const visible = readVisible();
|
||||
// 토량 분배는 켜 둔 첫 곡선(정식 우선)에만 얹는다 — B06과 같은 규칙.
|
||||
const banded = series.find((entry) => visible.has(entry.key));
|
||||
@@ -486,24 +495,30 @@ export function createRouteMassHaulPanel(
|
||||
wrap.append(chart);
|
||||
// 앵커는 첫 자식 — SVG 뒤면 흐름 위치가 밀려 스크롤 시 축이 안 보인다(B06과 같은 규칙).
|
||||
if (massAxis) wrap.prepend(buildStickyYAxis(massAxis, chartHeight));
|
||||
// 이미 종단과 맞물려 그린 곡선이면 제 자리 그대로 — 종단 값으로 되쓰면 그 사이 유토 쪽 휠이
|
||||
// 더 간 만큼 뒤로 튐(354-3). 처음 · 분배 맞춤 뒤 · 창 옮김 뒤만 종단 자리에서 시작한다.
|
||||
const keepLeft = !fitAll && syncedIn === scroll.ownerDocument ? scroll.scrollLeft : null;
|
||||
scroll.replaceChildren(wrap);
|
||||
// 종단 스크롤러가 이미 옆으로 가 있으면 새로 그린 곡선도 같은 자리에서 시작한다.
|
||||
// 전체 맞춤일 때는 스크롤할 것이 없으므로 왼쪽 끝에 둔다.
|
||||
if (fitAll) scroll.scrollLeft = 0;
|
||||
else if (syncedMain) scroll.scrollLeft = syncedMain.scrollLeft;
|
||||
else if (keepLeft !== null) scroll.scrollLeft = keepLeft;
|
||||
else if (syncedMain) putScroll?.(scroll, syncedMain.scrollLeft);
|
||||
syncedIn = fitAll ? null : scroll.ownerDocument;
|
||||
|
||||
legendLayer.append(
|
||||
createMassHaulLegend(series, visible, toggleSeries, () => {
|
||||
resetBalloonOffsets();
|
||||
onChanged();
|
||||
}),
|
||||
);
|
||||
// 범례는 계열 · 켬이 바뀔 때만 — 스크롤 그리기는 그대로 둔다.
|
||||
if (!reuse || !legendLayer.firstChild)
|
||||
legendLayer.replaceChildren(
|
||||
createMassHaulLegend(series, visible, toggleSeries, () => {
|
||||
resetBalloonOffsets();
|
||||
onChanged();
|
||||
}),
|
||||
);
|
||||
drawnOnce = true;
|
||||
// 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다.
|
||||
scheduleMassHaulSettle(
|
||||
windowState,
|
||||
() => {
|
||||
if (lastParams) draw(lastParams);
|
||||
if (lastParams) draw(lastParams, true);
|
||||
},
|
||||
overlay,
|
||||
);
|
||||
@@ -512,22 +527,12 @@ export function createRouteMassHaulPanel(
|
||||
return {
|
||||
handle,
|
||||
overlay,
|
||||
isOpen: () => open,
|
||||
desiredHeight() {
|
||||
const raw = parseFloat(overlay.style.getPropertyValue(HEIGHT_VAR));
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : OVERLAY_DEFAULT_HEIGHT;
|
||||
},
|
||||
commitHeight(px) {
|
||||
const next = Math.round(px);
|
||||
overlay.style.setProperty(HEIGHT_VAR, `${next}px`);
|
||||
writeStateRaw("masshaul-height", String(next));
|
||||
syncHandlePosition();
|
||||
},
|
||||
syncHandle: () => syncHandlePosition(),
|
||||
isOpen: shown,
|
||||
setContext(next) {
|
||||
context = next;
|
||||
},
|
||||
attachScrollSync,
|
||||
draw,
|
||||
draw: (params) => draw(params),
|
||||
drawWindow,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,7 +64,10 @@ export interface ProfileRenderContext {
|
||||
* 돌려주는 값 = 다시 셈을 기다리는 중(false 면 남은 낡음 — 곡선은 그리고 그 측점만 표시 · 342-1). */
|
||||
onStaleDesigns?: (key: string) => boolean;
|
||||
callbacks: RouteProfilePanelCallbacks | undefined;
|
||||
massHaul: { draw: (params: RouteMassHaulDrawParams) => void };
|
||||
massHaul: {
|
||||
draw: (params: RouteMassHaulDrawParams) => void;
|
||||
drawWindow: (params: RouteMassHaulDrawParams) => void;
|
||||
};
|
||||
tableOverlay: {
|
||||
isOpen: () => boolean;
|
||||
contentHeight: () => number;
|
||||
@@ -452,9 +455,9 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
// 유토곡선도 **같은 창**을 본다(2026-09-04 사용자 확정) — 스크롤할 때마다 곡선만 다시
|
||||
// 그린다. 종단처럼 변환으로 옮기지 않는 이유: 곡선 위에 앉는 말풍선·EP 표·측점 점이
|
||||
// 세로로 늘어나면 안 되는 것들이라, 그것만 따로 옮기는 값이 곡선 자체를 다시 그리는
|
||||
// 값보다 크다(요소 수가 종단의 1/3).
|
||||
// 값보다 크다(요소 수가 종단의 1/3). 열림 · 분배 맞춤 아닐 때만 · 한 틀 한 번 · 다시 셈 없음(354-3).
|
||||
massHaulUpdater = (fromM, toM) => {
|
||||
massHaul.draw({
|
||||
massHaul.drawWindow({
|
||||
...massHaulParams,
|
||||
axis: { ...massHaulParams.axis, viewRange: { fromM, toM } },
|
||||
});
|
||||
|
||||
@@ -4,17 +4,15 @@
|
||||
* 오버레이 서브패널로 감싼다(2026-08-05 사용자 지시).
|
||||
*
|
||||
* - 접힘/펼침 표준 삼각형 손잡이 + 위 경계 리사이저 + 세션 보존.
|
||||
* - 유토곡선 오버레이가 함께 열리면 그 **위**에 쌓인다 — 위→아래 순서가
|
||||
* 종단도 → 테이블 → 유토곡선이 되도록 setBottomOffset으로 바닥 간격을 받는다.
|
||||
* - 접혔을 때 손잡이는 유토곡선 손잡이와 가로로 나란히(일렬) 놓인다(CSS에서
|
||||
* 가로 오프셋). 테이블 내용물은 Profile_Panel의 draw()가 만들어 setTable로 넣는다.
|
||||
* - 종단 칸 바닥에 붙는다 — 유토곡선은 그 아래 자기 판이라 쌓지 않는다(354-1).
|
||||
* - 테이블 내용물은 Profile_Panel의 draw()가 만들어 setTable로 넣는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { TABLE_ROW_COUNT } from "./B05_Profile_UI_Profile_Layout";
|
||||
import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel";
|
||||
import { attachWheelHorizontalScroll, linkScrollLeft } from "./B05_Profile_UI_Profile_Wheel";
|
||||
import { frameOf } from "../common_util/common_util_view_window";
|
||||
|
||||
/* 펼침·높이는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
|
||||
@@ -54,8 +52,6 @@ export interface RouteProfileTableOverlay {
|
||||
setTable: (table: HTMLElement | null) => void;
|
||||
/** 종단 그래프 스크롤러와 가로 스크롤 양방향 동기화. */
|
||||
attachScrollSync: (main: HTMLElement) => void;
|
||||
/** 유토곡선 오버레이가 차지한 바닥 높이(px). 그 위로 쌓인다. */
|
||||
setBottomOffset: (px: number) => void;
|
||||
}
|
||||
|
||||
export function createProfileTableOverlay(onChanged: () => void): RouteProfileTableOverlay {
|
||||
@@ -73,7 +69,6 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
|
||||
// 상태 선언은 리사이저 생성보다 먼저 — 세션 높이 복원이 생성 중 onResize를 부른다
|
||||
// (유토곡선 TDZ 크래시와 같은 함정, 2026-08-04 확인).
|
||||
let open = readStateRaw("table-open") !== "false"; // 기본 펼침(기존 테이블 상시 표시 유지)
|
||||
let bottomOffset = 0;
|
||||
let resizeRedrawPending = false;
|
||||
const resizer = createPanelResizer({
|
||||
axis: "vertical",
|
||||
@@ -102,11 +97,9 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
|
||||
// 유토곡선 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(종단 스크롤도 함께 움직인다).
|
||||
attachWheelHorizontalScroll(scroll);
|
||||
|
||||
/** 손잡이는 오버레이 위 경계를, 접히면 열린 유토곡선의 위 경계(bottomOffset)를 따라간다
|
||||
* — 접힌 버튼이 펼쳐진 패널 수평선에 놓인다(2026-08-05 사용자 지시). */
|
||||
/** 손잡이는 오버레이 위 경계를, 접히면 종단 칸 바닥을 따라간다(2026-08-05 사용자 지시). */
|
||||
function syncHandlePosition(): void {
|
||||
handle.style.bottom = open ? `${bottomOffset + overlay.offsetHeight}px` : `${bottomOffset}px`;
|
||||
overlay.style.bottom = `${bottomOffset}px`;
|
||||
handle.style.bottom = open ? `${overlay.offsetHeight}px` : "0px";
|
||||
}
|
||||
|
||||
function applyOpen(next: boolean): void {
|
||||
@@ -125,16 +118,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
|
||||
overlay.hidden = !open;
|
||||
handleControl.root.addEventListener("click", () => applyOpen(!open));
|
||||
|
||||
// 종단 스크롤러와의 양방향 동기화 — 재진입 루프는 플래그로 끊는다(유토곡선과 동일).
|
||||
let syncing = false;
|
||||
function mirror(from: HTMLElement, to: HTMLElement): void {
|
||||
from.addEventListener("scroll", () => {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
to.scrollLeft = from.scrollLeft;
|
||||
syncing = false;
|
||||
});
|
||||
}
|
||||
// 종단 스크롤러와의 양방향 동기화 — 메아리는 공용 도우미가 걸러 냄(유토곡선과 같음 · 354-3).
|
||||
let syncedMain: HTMLElement | null = null;
|
||||
|
||||
return {
|
||||
@@ -161,12 +145,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
|
||||
attachScrollSync(main) {
|
||||
if (syncedMain === main) return;
|
||||
syncedMain = main;
|
||||
mirror(main, scroll);
|
||||
mirror(scroll, main);
|
||||
},
|
||||
setBottomOffset(px) {
|
||||
bottomOffset = px;
|
||||
syncHandlePosition();
|
||||
linkScrollLeft(main, scroll);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,6 +39,35 @@ const BASE_FRAME_MS = 1000 / 60;
|
||||
* 쓰면 한 번에 목표까지 튄다. */
|
||||
const MAX_FRAME_MS = 64;
|
||||
|
||||
/**
|
||||
* 두 스크롤러의 가로 자리를 서로 따라가게 잇는다 — 종단 그래프 ↔ 유토곡선 · 표(354-3).
|
||||
* 써 넣은 값으로 **다음 틀에** 오는 scroll(메아리)은 무시한다. 동기 플래그로 막던 예전 길은
|
||||
* 메아리가 한 틀 전 값을 되써 앞서 간 쪽을 뒤로 튀게 했고, 그 튐이 휠 이동을 접어 끊겼다.
|
||||
* `paused` 가 참이면 안 따라감. 돌려주는 `put` = 따라가는 쪽에 손으로 써 넣을 때(메아리 표시).
|
||||
*/
|
||||
export function linkScrollLeft(
|
||||
a: HTMLElement,
|
||||
b: HTMLElement,
|
||||
paused: () => boolean = () => false,
|
||||
): (to: HTMLElement, left: number) => void {
|
||||
const echo = new Map<HTMLElement, number>();
|
||||
const put = (to: HTMLElement, left: number): void => {
|
||||
const before = to.scrollLeft;
|
||||
to.scrollLeft = left;
|
||||
if (to.scrollLeft !== before) echo.set(to, to.scrollLeft);
|
||||
};
|
||||
const follow = (from: HTMLElement, to: HTMLElement): void =>
|
||||
from.addEventListener("scroll", () => {
|
||||
const wrote = echo.get(from);
|
||||
echo.delete(from);
|
||||
if (wrote !== undefined && Math.abs(from.scrollLeft - wrote) < 1) return;
|
||||
if (!paused() && to.scrollLeft !== from.scrollLeft) put(to, from.scrollLeft);
|
||||
});
|
||||
follow(a, b);
|
||||
follow(b, a);
|
||||
return put;
|
||||
}
|
||||
|
||||
export function attachWheelHorizontalScroll(scroller: HTMLElement): void {
|
||||
let target = -1;
|
||||
/** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */
|
||||
|
||||
@@ -28,12 +28,14 @@ import {
|
||||
loadRouteEditBackground,
|
||||
type RouteEditContours,
|
||||
} from "./B05_Profile_UI_RouteEdit_Contour";
|
||||
import { createContourLock } from "./B05_Profile_UI_RouteEdit_ContourLock";
|
||||
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
|
||||
import {
|
||||
analysisTerrain,
|
||||
fetchRoutePlan,
|
||||
fetchTerrainSources,
|
||||
setAnalysisTerrain,
|
||||
setTerrainContext,
|
||||
type AnalysisTerrain,
|
||||
} from "./B05_Profile_Api_Replan";
|
||||
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
|
||||
@@ -89,6 +91,7 @@ import {
|
||||
} from "./B05_Profile_UI_RouteEdit_History";
|
||||
import type { DesignPageHost } from "../A00_Common/design/design_page";
|
||||
import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft";
|
||||
import { bindRouteShift } from "./B05_Profile_UI_RouteEdit_Shift";
|
||||
import "./B05_Profile_UI_Style_RouteEdit.css";
|
||||
|
||||
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
|
||||
@@ -184,11 +187,17 @@ export async function mountRouteEditor(
|
||||
let designTerrain: AnalysisTerrain = "las";
|
||||
/** [확인]에 실을 분석 지형 — 고르기가 안 보이면 null(설계 지형 그대로). */
|
||||
const pickedTerrain = (): AnalysisTerrain | null =>
|
||||
terrainSelect.closest("label")!.hidden ? null : analysisTerrain(projectId, designTerrain);
|
||||
terrainSelect.closest("label")!.hidden ? null : analysisTerrain(projectId);
|
||||
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
|
||||
let otherSheets: PreparedLayer[] = [];
|
||||
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
|
||||
let pickedContour = -1;
|
||||
/** 등고선 자물쇠(353-2) — 잠긴 동안 빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜. */
|
||||
const contourLock = createContourLock(
|
||||
canvas,
|
||||
() => contours && { layer: contours.layer, view, stepM: contourStepM(), rotation },
|
||||
(index) => ((pickedContour = index), draw()),
|
||||
);
|
||||
/** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */
|
||||
const crossPreview = createCrossPreview({
|
||||
projectId,
|
||||
@@ -284,13 +293,6 @@ export async function mountRouteEditor(
|
||||
planMinRadiusM: () => minRadiusM,
|
||||
apply: (snapshot, message) => restoreSnapshot(snapshot, message, true),
|
||||
});
|
||||
// [저장 전으로] — 마지막 저장(페이지를 세운 때) 노선 · 그 걸음의 「현재 추천」으로. 되돌리기로 다시 살림.
|
||||
overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", () => {
|
||||
const snapshot = history?.reset();
|
||||
if (!snapshot) return showToast("반영 뒤 고친 노선이 없습니다.", "info");
|
||||
initial.restoreTag(snapshot.rec);
|
||||
restoreSnapshot(snapshot, "반영 전 노선으로 되돌렸습니다.", false);
|
||||
});
|
||||
|
||||
function resize(): void {
|
||||
if (closed || !overlay.isConnected) return;
|
||||
@@ -521,7 +523,7 @@ export async function mountRouteEditor(
|
||||
picked = index;
|
||||
},
|
||||
setPickedContour: (index) => {
|
||||
pickedContour = index;
|
||||
if (contourLock.canPick()) pickedContour = index;
|
||||
},
|
||||
toScreen,
|
||||
toMetric,
|
||||
@@ -627,32 +629,33 @@ export async function mountRouteEditor(
|
||||
history = createRouteEditHistory(snapshotNow());
|
||||
history = draftLink.watch(history, options.draft, () => stationSnap);
|
||||
draftLink.restore(options.draft, restoreSnapshot);
|
||||
bindRouteShift(host, projectId, snapshotNow, applyEdit, () => [plannedLine, curveInfo]); // 356-3
|
||||
historyControls.sync();
|
||||
syncCurveBar();
|
||||
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
|
||||
meta = drainage.meta;
|
||||
// 분석 지형 고르기(PLAN 133-1) — 라이다 · 수치지형도 둘 다 있을 때만 보임 · 기본 라이다.
|
||||
// 고른 값이 없으면 지금 설계 지형(133-2) · 못 고르는 프로젝트는 설계 지형 그대로.
|
||||
// 분석 지형 고르기(PLAN 133-1) — 라이다 · 수치지형도 둘 다 있을 때만 보임 · 열 때 안 씀(352-3).
|
||||
// 고른 적 없으면 데이터대로(설계 지형 · 라이다가 있으면 라이다) · 사용자가 바꿀 때만 기억.
|
||||
designTerrain = sources?.design ?? "las";
|
||||
const choosable = !!sources?.sources.includes("las") && !!sources?.sources.includes("sheet");
|
||||
terrainSelect.closest("label")!.hidden = !choosable;
|
||||
setAnalysisTerrain(
|
||||
projectId,
|
||||
choosable ? analysisTerrain(projectId, designTerrain) : designTerrain,
|
||||
);
|
||||
setTerrainContext(projectId, choosable, designTerrain);
|
||||
terrainSelect.value = analysisTerrain(projectId);
|
||||
// 등고선은 따로 고른다(LAS 우선 · 수치지형도를 고르면 도엽). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다.
|
||||
// 확정 모델 등고선은 설계 지형이 라이다일 때만 라이다 것 — 수치지형도 설계면 도엽 선(글도 도엽).
|
||||
const loadBackground = () =>
|
||||
loadRouteEditBackground(projectId, drainage, {
|
||||
surfaceModelId:
|
||||
analysisTerrain(projectId) === "sheet" ? null : (options.surfaceModelId ?? null),
|
||||
analysisTerrain(projectId) === "las" && designTerrain === "las"
|
||||
? (options.surfaceModelId ?? null)
|
||||
: null,
|
||||
intervalM: options.contourIntervalM ?? 1,
|
||||
smooth: options.smooth ?? false,
|
||||
});
|
||||
const useBackground = (background: Awaited<ReturnType<typeof loadBackground>>): void => {
|
||||
otherSheets = background.otherSheets;
|
||||
contours = background.contours;
|
||||
pickedContour = -1;
|
||||
pickedContour = contourLock.reattach(contours?.layer ?? null);
|
||||
// 1m 선이 있는 자료(LAS)일 때만 단추를 연다 — 도엽에는 1m 선이 없다.
|
||||
minorContourButton.disabled = !contours?.minor;
|
||||
minorContourButton.title = contours?.minor
|
||||
@@ -672,6 +675,9 @@ export async function mountRouteEditor(
|
||||
let terrainTicket = 0;
|
||||
terrainSelect.addEventListener("change", () => {
|
||||
setAnalysisTerrain(projectId, terrainSelect.value === "sheet" ? "sheet" : "las");
|
||||
// 계산 묶음에 알림 — 노선이 그대로여도 지형이 설계와 다르면 초안으로 다시 셈(352-3).
|
||||
const dirty = history?.isDirty() || pickedTerrain() !== designTerrain;
|
||||
options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), stationSnap) : null);
|
||||
const mine = ++terrainTicket;
|
||||
void loadBackground().then((background) => {
|
||||
if (closed || mine !== terrainTicket) return;
|
||||
|
||||
@@ -8,13 +8,13 @@ import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint";
|
||||
* `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 생김새만 있고
|
||||
* 동작은 없다 — 배선은 본체와 각 조각(`_Apply`·`_Rotate`·`_History`…)이 한다.
|
||||
*
|
||||
* **배치**(2026-09-12 사용자 지시 ⑩~⑬·⑱) — 아래 정보행을 없애고 지도 위 오버레이로 옮겼다.
|
||||
* 단추는 제목행 오른쪽, 조작 설명은 지도 왼쪽 위 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽
|
||||
* 아래. 메인 창은 왼쪽으로 밀고 오른쪽 세로 칸에 횡단 두 판이 앉는다.
|
||||
* **배치**(352-1) — 지도가 칸 전체 · 머리 줄 · 테두리 없음. 도구는 3D 와 같은 떠 있는 유리 단추
|
||||
* (지도 왼쪽 위 · ☰ 비킴), [?] 조작 설명은 도구줄 아래 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽
|
||||
* 아래. 횡단 두 판은 지도 오른쪽을 덮는 판(아래 종단 판이 그 위).
|
||||
*
|
||||
* 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 ·
|
||||
* 닫기 단추는 걷었다 · [취소] → [저장 전으로] · [확인] · [노선 반영]은 없앰(346-3 — 노선은 페이지 [저장]이
|
||||
* 맨 앞에서 보냄) · 오른쪽 횡단 칸은 접는 칸.
|
||||
* 닫기 단추는 걷었다 · [취소] · [확인] · [노선 반영] · [저장 전으로]는 없앰(346-3 · 352-2 — 노선은 페이지
|
||||
* [저장]이 맨 앞에서 보냄) · 오른쪽 횡단 칸은 접는 칸.
|
||||
* 머리 `tools` 자리에 부른 쪽 단추([3D])가 앉는다. [노선 추천] · 현재 추천 · [초기화]는 좌측
|
||||
* 「노선 추천」 칸으로 옮겼다(345-4 · `_Panel_Recommend`).
|
||||
* ========================================================================== */
|
||||
@@ -57,12 +57,11 @@ export function createRouteEditChrome(
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "b05-routeedit";
|
||||
overlay.innerHTML = `
|
||||
<div class="b05-routeedit__box" aria-label="계획노선 편집">
|
||||
<div class="b05-routeedit__head">
|
||||
<h3>계획노선</h3>
|
||||
<span class="b05-routeedit__actions">
|
||||
<div class="b05-routeedit__canvas-wrap" aria-label="계획노선 편집">
|
||||
<canvas class="b05-routeedit__canvas"></canvas>
|
||||
<div class="b05-route__view-controls b05-routeedit__tools">
|
||||
<span data-slot="tools"></span>
|
||||
<i class="b05-routeedit__divider" aria-hidden="true"></i>
|
||||
<i class="b05-route__view-separator" aria-hidden="true"></i>
|
||||
<label class="b05-routeedit__terrain" hidden
|
||||
title="노선 편집 분석(등고선 · 지반고 · 기울기 · 횡단 미리보기 · 추천)에 쓸 지형 — 확정 지표면 · 종횡단은 안 바뀝니다">분석 지형
|
||||
<select class="ui-select ui-select--sm" data-act="terrain">
|
||||
@@ -70,29 +69,24 @@ export function createRouteEditChrome(
|
||||
<option value="sheet">수치지형도</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="fit"
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="fit"
|
||||
title="등고 영역(등고선 범위 · 노선)을 평면 가운데 알맞게 맞춥니다 (회전은 풂)">맞춤</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="contour-minor"
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="contour-minor"
|
||||
aria-pressed="false" disabled
|
||||
title="1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)">1m 등고선</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="measure"
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="measure"
|
||||
title="노선 위 두 점을 눌러 거리·기울기를 잽니다 (Shift+클릭도 같음)">거리 재기</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="station-snap"
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="station-snap"
|
||||
aria-pressed="false"
|
||||
title="곡선(호) 안 측점 하나를 곡선 중점(MC)으로 옮깁니다 — [저장] 때 저장">측점 스냅</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="footprint"
|
||||
title="도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 파랑)을 평면에 겹칩니다">발자국</button>
|
||||
<i class="b05-routeedit__divider" aria-hidden="true"></i>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="undo"
|
||||
title="되돌리기 (Ctrl+Z)" disabled>↶ 되돌리기</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="redo"
|
||||
title="다시하기 (Ctrl+Y)" disabled>↷ 다시하기</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel"
|
||||
title="마지막 [저장] 뒤 고친 노선을 버리고 그때 노선으로 되돌립니다 — 되돌리기로 다시 살릴 수 있습니다">저장 전으로</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="b05-routeedit__canvas-wrap">
|
||||
<canvas class="b05-routeedit__canvas"></canvas>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="footprint"
|
||||
title="도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 파랑)을 평면에 겹칩니다">절/성토선</button>
|
||||
<i class="b05-route__view-separator" aria-hidden="true"></i>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="undo"
|
||||
title="되돌리기 (Ctrl+Z)" aria-label="되돌리기" disabled>↶</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="redo"
|
||||
title="다시하기 (Ctrl+Y)" aria-label="다시하기" disabled>↷</button>
|
||||
</div>
|
||||
<div class="b05-routeedit__hint">
|
||||
<button type="button" class="ui-btn ui-btn--glass b05-routeedit__hint-toggle"
|
||||
data-act="hint-toggle" aria-expanded="false" title="조작 도움말 펼치기 · 접기">?</button>
|
||||
@@ -123,15 +117,16 @@ export function createRouteEditChrome(
|
||||
<button type="button" class="b05-routeedit__info-toggle" data-act="info-toggle"
|
||||
aria-expanded="true" title="상태 · 범례 글판 접기 · 펴기">▾</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b05-routeedit__busy" hidden><span></span></div>
|
||||
</div>
|
||||
<div class="b05-routeedit__busy" hidden><span></span></div>
|
||||
<aside class="b05-routeedit__side-wrap">
|
||||
<button type="button" class="ui-btn ui-btn--ghost b05-routeedit__side-toggle"
|
||||
<button type="button" class="ui-btn ui-btn--glass b05-routeedit__side-toggle"
|
||||
data-act="side-toggle" aria-expanded="true" title="오른쪽 횡단 미리보기 칸 접기 · 펴기">횡단</button>
|
||||
<div class="b05-routeedit__side"></div>
|
||||
</aside>`;
|
||||
parent.append(overlay);
|
||||
// 부른 쪽 단추([3D])도 같은 유리 단추로 — 지도 위에 떠 있는 도구줄.
|
||||
tools.forEach((tool) => tool.classList.replace("ui-btn--ghost", "ui-btn--glass"));
|
||||
overlay.querySelector('[data-slot="tools"]')!.replaceWith(...tools);
|
||||
|
||||
// 횡단 미리보기 = 접는 칸 — 좁은 화면은 접힌 채 시작(지도가 먼저다).
|
||||
@@ -139,6 +134,7 @@ export function createRouteEditChrome(
|
||||
const sideToggle = overlay.querySelector<HTMLButtonElement>('[data-act="side-toggle"]')!;
|
||||
const setSide = (open: boolean): void => {
|
||||
sideWrap.classList.toggle("is-collapsed", !open);
|
||||
overlay.classList.toggle("is-side-open", open); // 회전 단추 · 잰 값 창이 판을 비킴
|
||||
sideToggle.setAttribute("aria-expanded", String(open));
|
||||
};
|
||||
setSide(window.innerWidth >= 1500);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_ContourLock.ts
|
||||
* 평면 등고선 자물쇠(353-2) — 등고선 위를 지나거나 고른 가닥 위에 뜨는 작은 단추.
|
||||
*
|
||||
* 누르면 그 높이를 고정(빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜) · 다시 누르면 풂.
|
||||
* 높이값으로 쥐어 지형을 바꿔도 같은 높이를 다시 짚음 · 페이지 동안만(저장 안 함).
|
||||
* 규칙은 `logic/B05_Profile_ContourLock` · 호버 판정은 집기와 같은 `hitPreparedLayer`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pickPx } from "../A00_Common/touch_bridge";
|
||||
import {
|
||||
hitPreparedLayer,
|
||||
type PreparedLayer,
|
||||
type ViewState,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import {
|
||||
canPickContour,
|
||||
featureOfLevel,
|
||||
lockButtonState,
|
||||
toggleLock,
|
||||
} from "./logic/B05_Profile_ContourLock";
|
||||
import "./B05_Profile_UI_Style_RouteEdit_ContourLock.css";
|
||||
|
||||
/** 집기(`_Pointer` CONTOUR_HIT_PX)와 같은 거리(px). */
|
||||
const HOVER_HIT_PX = 6;
|
||||
/** 커서가 선을 떠난 뒤 단추가 남는 시간(ms) — 단추로 옮겨 갈 틈. */
|
||||
const HIDE_DELAY_MS = 700;
|
||||
/** 단추는 커서 오른쪽 위로 이만큼(px) — 선과 커서를 안 가림. */
|
||||
const OFFSET_PX = 12;
|
||||
|
||||
const LOCK_SVG = `<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
|
||||
<rect x="3" y="7" width="10" height="7" rx="1.5" fill="currentColor"/>
|
||||
<path class="b05-contourlock__shackle" d="M5 7V5a3 3 0 0 1 6 0v2" fill="none"
|
||||
stroke="currentColor" stroke-width="1.6"/></svg>`;
|
||||
|
||||
/** 호버 판정에 쓰는 지금 화면 — 등고선이 없으면 null. */
|
||||
export interface ContourLockScene {
|
||||
layer: PreparedLayer;
|
||||
view: ViewState;
|
||||
/** 지금 화면 등고선 간격 — 그리지 않은 줄은 호버도 안 됨. */
|
||||
stepM: number;
|
||||
rotation: { unrotate: (px: number, py: number) => [number, number] };
|
||||
}
|
||||
|
||||
export interface ContourLock {
|
||||
/** 고른 가닥을 바꿔도 되는지(잠긴 동안 false). */
|
||||
canPick: () => boolean;
|
||||
/** 지형을 바꿔 새 가닥들이 왔다 — 잠근 높이의 가닥 번호(없으면 -1). */
|
||||
reattach: (layer: PreparedLayer | null) => number;
|
||||
}
|
||||
|
||||
/** 자물쇠 단추를 캔버스 칸 위에 세움 · `pick` = 잠글 때 그 가닥을 고른 것으로(다시 그리기까지). */
|
||||
export function createContourLock(
|
||||
canvas: HTMLCanvasElement,
|
||||
scene: () => ContourLockScene | null,
|
||||
pick: (index: number) => void,
|
||||
): ContourLock {
|
||||
const wrap = canvas.parentElement ?? document.body;
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "ui-btn ui-btn--glass b05-contourlock";
|
||||
button.dataset.act = "contour-lock";
|
||||
button.hidden = true;
|
||||
button.innerHTML = LOCK_SVG;
|
||||
wrap.append(button);
|
||||
|
||||
let lockedLevel: number | null = null;
|
||||
/** 단추가 가리키는 가닥 — 누르면 이 가닥을 잠금. */
|
||||
let target = -1;
|
||||
let hideTimer = 0;
|
||||
let frame = 0;
|
||||
let last: PointerEvent | null = null;
|
||||
|
||||
const levelOf = (index: number): number | null =>
|
||||
index >= 0 ? (scene()?.layer.features[index]?.labelValue ?? null) : null;
|
||||
|
||||
const paint = (): void => {
|
||||
const pressed = lockedLevel !== null;
|
||||
button.setAttribute("aria-pressed", String(pressed));
|
||||
const label = pressed
|
||||
? `등고선 ${lockedLevel}m 고정 풀기 — 풀면 다른 등고선을 고를 수 있음`
|
||||
: `등고선 ${levelOf(target) ?? ""}m 고정 — 빈 곳 · 다른 등고선을 눌러도 안 바뀜`;
|
||||
button.setAttribute("aria-label", label);
|
||||
button.title = label;
|
||||
};
|
||||
|
||||
const hideLater = (): void => {
|
||||
if (hideTimer) return;
|
||||
hideTimer = window.setTimeout(() => {
|
||||
hideTimer = 0;
|
||||
button.hidden = true;
|
||||
}, HIDE_DELAY_MS);
|
||||
};
|
||||
const keep = (): void => {
|
||||
window.clearTimeout(hideTimer);
|
||||
hideTimer = 0;
|
||||
};
|
||||
|
||||
const hover = (event: PointerEvent): void => {
|
||||
const now = scene();
|
||||
// 단추를 누른 채(노드 · 손잡이 끌기 · 화면 이동) 지나가면 안 봄
|
||||
if (!now || event.buttons !== 0) return hideLater();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const [px, py] = now.rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
|
||||
const hitPx = pickPx(HOVER_HIT_PX);
|
||||
const index = hitPreparedLayer(now.layer, now.view, px, py, hitPx, now.stepM);
|
||||
const state = lockButtonState(levelOf(index), lockedLevel);
|
||||
if (!state.visible) return hideLater();
|
||||
keep();
|
||||
target = index;
|
||||
const origin = wrap.getBoundingClientRect();
|
||||
button.style.left = `${Math.round(event.clientX - origin.left + OFFSET_PX)}px`;
|
||||
button.style.top = `${Math.round(event.clientY - origin.top - OFFSET_PX)}px`;
|
||||
button.hidden = false;
|
||||
paint();
|
||||
};
|
||||
|
||||
// 한 프레임에 한 번만 잼 — 도엽 등고선은 수천 가닥.
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
last = event;
|
||||
if (frame) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
if (last) hover(last);
|
||||
});
|
||||
});
|
||||
// 고른 직후(누름을 뗀 자리)에도 띄움 — 터치는 호버가 없음.
|
||||
canvas.addEventListener("pointerup", hover);
|
||||
canvas.addEventListener("pointerleave", hideLater);
|
||||
button.addEventListener("pointerenter", keep);
|
||||
button.addEventListener("pointerleave", hideLater);
|
||||
button.addEventListener("pointerdown", (event) => event.stopPropagation());
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
lockedLevel = toggleLock(lockedLevel, levelOf(target));
|
||||
if (lockedLevel !== null) pick(target);
|
||||
paint();
|
||||
});
|
||||
|
||||
return {
|
||||
canPick: () => canPickContour(lockedLevel),
|
||||
reattach: (layer) => {
|
||||
target = -1;
|
||||
button.hidden = true;
|
||||
return featureOfLevel(
|
||||
layer?.features.map((feature) => feature.labelValue) ?? [],
|
||||
lockedLevel,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Draft.ts
|
||||
* 노선 편집기 ↔ 계산 묶음 다리(PLAN 346-1) — 노선을 한 번 고칠 때마다(놓기 · 더함 · 지움 · 곡선 ·
|
||||
* 되돌리기 · 다시하기 · [저장 전으로] · 추천 [적용]) 노선 초안을 넘김. 끄는 동안은 넘기지 않음
|
||||
* 되돌리기 · 다시하기 · 추천 [적용]) 노선 초안을 넘김. 끄는 동안은 넘기지 않음
|
||||
* (사진첩에 쌓이는 때 = 손을 놓은 때).
|
||||
*
|
||||
* 편집기는 사진첩(`_History`)을 `watch` 로 감싸기만 함 — 고친 줄 하나. 저장 안 한 초안은
|
||||
* 다리가 캐시 칸(`route-draft`)에 두고, 편집기가 자료를 읽은 뒤 `restore` 로 한 번 되살림
|
||||
* (새로고침 · 보기 전환에도 남음). 계산 · 가게는 `A00_Common/design/design_pipeline_page.ts` 몫.
|
||||
*
|
||||
* 되돌리기 줄(346-4) — 페이지 사진첩(`_Profile_History`)이 있으면 쌓기 · [저장 전으로] 뒤 사진첩에 한 걸음 ·
|
||||
* 되돌리기 줄(346-4) — 페이지 사진첩(`_Profile_History`)이 있으면 쌓기 ·
|
||||
* [되돌리기] · [다시하기]는 사진첩이 함(평면 · 종단 · 횡단 · 관 · 구조물 한 줄) · 사진첩이 되쓴 노선
|
||||
* 초안은 `fromPage` 로 편집기 새 걸음이 됨. 사진첩이 없으면 편집기 사진 그대로.
|
||||
* ========================================================================== */
|
||||
@@ -99,11 +99,6 @@ export function watch(
|
||||
album.redo();
|
||||
return null;
|
||||
},
|
||||
reset: () => {
|
||||
const snapshot = tell(history.reset());
|
||||
if (snapshot) pageHistory()?.record();
|
||||
return snapshot;
|
||||
},
|
||||
canUndo: () => pageHistory()?.canUndo() ?? history.canUndo(),
|
||||
canRedo: () => pageHistory()?.canRedo() ?? history.canRedo(),
|
||||
fromPage,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*
|
||||
* 되돌리기 줄은 페이지 사진첩 하나(`_Profile_History` · 346-4) — 계산 묶음 다리가 있으면(`_Draft.watch`)
|
||||
* [되돌리기] · [다시하기] · Ctrl+Z · Y 는 사진첩이 받고, 여기 사진은 고정 · 추천 꼬리표 · 고른 점을
|
||||
* 되살리는 기록(`match`)과 [저장 전으로](`reset`) · 고친 것 있음(`isDirty`)에만 씀.
|
||||
* 되살리는 기록(`match`) · 고친 것 있음(`isDirty`)에만 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import { onPageHistory } from "./B05_Profile_UI_Profile_History";
|
||||
@@ -44,8 +44,6 @@ export interface RouteEditHistory {
|
||||
undo: () => RouteEditSnapshot | null;
|
||||
/** 한 걸음 앞으로. 없으면 null. */
|
||||
redo: () => RouteEditSnapshot | null;
|
||||
/** 창을 연 상태로. 이미 그 상태면 null. */
|
||||
reset: () => RouteEditSnapshot | null;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
/** 창을 연 뒤로 고친 것이 있나 — [초기화]를 켤지 정한다. */
|
||||
@@ -115,15 +113,6 @@ export function createRouteEditHistory(initial: RouteEditSnapshot): RouteEditHis
|
||||
at += 1;
|
||||
return clone(steps[at]);
|
||||
},
|
||||
reset() {
|
||||
// 이미 연 상태 그대로면 할 일이 없다 — 눌러도 걸음만 늘어난다.
|
||||
if (sameRoute(steps[at], steps[0])) return null;
|
||||
// 초기화도 **되돌릴 수 있어야** 한다 — 첫 사진을 새 걸음으로 쌓는다.
|
||||
steps.length = at + 1;
|
||||
steps.push(clone(steps[0]));
|
||||
at = steps.length - 1;
|
||||
return clone(steps[at]);
|
||||
},
|
||||
canUndo: () => at > 0,
|
||||
canRedo: () => at < steps.length - 1,
|
||||
isDirty: () => !sameRoute(steps[at], steps[0]),
|
||||
|
||||
@@ -197,7 +197,7 @@ export function createCurveLabel(
|
||||
lockApex.dataset.act = "lock-apex";
|
||||
root.querySelector('[data-slot="lock-apex"]')!.replaceWith(lockApex);
|
||||
|
||||
/** 끝점에서는 숨길 곡선 칸 — 곡선 지우기 단추 · 반지름 줄 · 곡선 길이 줄 · 내각 글. */
|
||||
/** 끝점에서는 숨길 곡선 칸 — 곡선 삭제 단추 · 반지름 줄 · 곡선 길이 줄 · 내각 글. */
|
||||
const curveOnly = [toggle, radiusField.wrapper, arcField.wrapper, info];
|
||||
|
||||
// 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다.
|
||||
@@ -327,7 +327,7 @@ export function createCurveLabel(
|
||||
lockApex.title = end
|
||||
? `${name} 고정 — 켜면 끌어 옮길 수 없습니다. 풀면 끌어 옮깁니다`
|
||||
: "교각점 고정 — 이 꺾임점의 자리를 못 박습니다. 노드도 손잡이도 안 끌리고 반지름·곡선 길이 칸으로만 바뀝니다";
|
||||
toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기";
|
||||
toggle.textContent = state.curveOn ? "곡선 삭제" : "곡선 추가";
|
||||
radius.disabled = !state.curveOn;
|
||||
arc.disabled = !state.curveOn;
|
||||
lockRadius.disabled = !state.curveOn;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Shift.ts
|
||||
* 횡단 중심점 좌우 → 노선 편집(356-3) — B06 카드가 부르는 host(`setRouteShiftHost`)를 편집기가 등록.
|
||||
*
|
||||
* 한 번 = 노드 옮김 · 끼움 한 번(고름은 `logic/B05_Profile_RouteShift`) → 편집기 한 걸음(applyEdit) →
|
||||
* 계산 묶음이 뒤 측점을 조용히 다시 셈 → 새 종단이 오면 그 측점 계획고를 옮기기 전 값으로(바뀐 구간 첫
|
||||
* 계획선 맞춤에 휩쓸리지 않게 · 계획고 편집). 둘을 페이지 되돌리기 한 걸음으로(사진첩 pause · resume).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline";
|
||||
import { pagePipeline, pipelineGrade } from "../A00_Common/design/design_pipeline_page";
|
||||
import { setRouteShiftHost } from "../B06_Section/B06_Section_UI_Cross_Center";
|
||||
import { pageHistory } from "./B05_Profile_UI_Profile_History";
|
||||
import type { RouteEditHost } from "./B05_Profile_UI_RouteEdit";
|
||||
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
|
||||
import type { EditedCurve } from "./logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import { spliceNode } from "./logic/B05_Profile_UI_RouteEdit_Edits";
|
||||
import {
|
||||
emptyEdits,
|
||||
planElevationAt,
|
||||
type AlignmentEdits,
|
||||
} from "./logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { pinStationHeight, planRouteShift } from "./logic/B05_Profile_RouteShift";
|
||||
|
||||
type Vertex = [number, number];
|
||||
/** 새 종단을 기다리는 한계(ms) — 넘으면 높이 보정 없이 한 걸음을 닫음. */
|
||||
const PROFILE_WAIT_MS = 10000;
|
||||
|
||||
/** 다음 조작의 종단 단계(가장 늦은 차례)가 끝날 때 true · 한계를 넘으면 false. */
|
||||
function nextProfile(pipe: DesignPipeline): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const done = (ok: boolean): void => {
|
||||
clearTimeout(timer);
|
||||
stop();
|
||||
resolve(ok);
|
||||
};
|
||||
const timer = setTimeout(() => done(false), PROFILE_WAIT_MS);
|
||||
const stop = pipe.subscribe((event) => {
|
||||
if (
|
||||
event.kind === "stage" &&
|
||||
event.stage === "profile" &&
|
||||
event.generation === pipe.generation
|
||||
)
|
||||
done(true);
|
||||
}, "중심점 좌우");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집기가 자료를 읽은 뒤 부름 — `columns` = 지금 편집값(산 배열 · 끼움 · 옮김이 그 자리를 고침) ·
|
||||
* `apply` = 편집기 한 걸음 · `drawn` = [그린 계획노선, 곡선표]. 편집기가 떠나면 host 를 뗌.
|
||||
*/
|
||||
export function bindRouteShift(
|
||||
host: RouteEditHost,
|
||||
projectId: string,
|
||||
columns: () => RouteEditSnapshot,
|
||||
apply: (message: string) => void,
|
||||
drawn: () => [Vertex[], EditedCurve[]],
|
||||
): void {
|
||||
setRouteShiftHost(async (chainageM, deltaM) => {
|
||||
const pipe = pagePipeline(projectId);
|
||||
if (pipe && !pipe.output("profile")) await nextProfile(pipe); // 막 세운 묶음(B06 바로 열기) — 첫 셈
|
||||
const now = columns();
|
||||
const [line, curves] = drawn();
|
||||
const plan = planRouteShift({
|
||||
planned: now.planned,
|
||||
line: line.length ? line : now.planned,
|
||||
curves,
|
||||
locked: now.apexLock,
|
||||
chainageM,
|
||||
deltaM,
|
||||
});
|
||||
if (!plan) return "노선을 아직 못 읽었습니다.";
|
||||
if (plan.kind === "locked") return "고정한 노드라 옮기지 않았습니다 — 고정을 먼저 푸세요.";
|
||||
const profile = pipe?.output("profile");
|
||||
const zBefore = profile ? planElevationAt(profile, chainageM) : null;
|
||||
const album = pageHistory();
|
||||
album?.pause();
|
||||
try {
|
||||
const routed = pipe ? nextProfile(pipe) : null;
|
||||
if (plan.kind === "move") now.planned[plan.index] = plan.to;
|
||||
else spliceNode(now, plan.index, plan.at);
|
||||
const side = deltaM > 0 ? "좌" : "우";
|
||||
apply(`횡단 중심점 — 측점 노선을 ${side}로 ${Math.abs(deltaM).toFixed(1)}m 옮겼습니다.`);
|
||||
if (!pipe || zBefore === null || !(await routed)) return null;
|
||||
const base = pipe.output("planline");
|
||||
const edits = (pipe.input("alignment") as AlignmentEdits | null) ?? emptyEdits();
|
||||
const pinned = base && pinStationHeight(base, edits, chainageM, zBefore);
|
||||
if (!pinned) return null;
|
||||
const graded = nextProfile(pipe);
|
||||
pipelineGrade(projectId, pinned);
|
||||
await graded;
|
||||
return null;
|
||||
} finally {
|
||||
album?.resume();
|
||||
}
|
||||
});
|
||||
host.onDispose(() => setRouteShiftHost(null));
|
||||
}
|
||||
@@ -43,26 +43,69 @@
|
||||
}
|
||||
|
||||
/* 본문 칸을 세로로 쌓아 그래프 스크롤러 아래에 손잡이가 붙게 한다.
|
||||
오버레이의 절대배치 기준이기도 하다. */
|
||||
테이블 겹침 판의 절대배치 기준이기도 하다. */
|
||||
.b05-route-profile__body-wrap {
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 오버레이 서브패널 — 패널 바닥(접기 손잡이 위)에 붙어 종단도·테이블 위를 덮는다
|
||||
(2026-08-04 사용자 확정). 높이는 위 경계 리사이저가 --b05-masshaul-height로 조절한다. */
|
||||
.b05-profile__masshaul-overlay {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0; /* 손잡이는 표준 삼각형(중앙, z-index 6)이라 자리를 차지하지 않는다 */
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
/* 종단 열 = 종단 칸 + 그 아래 유토곡선 판(354-1) — 배수유역도는 열 오른쪽. */
|
||||
.b05-route-profile__column {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 유토곡선 판 — 종단 칸 아래 자기 칸(354-1). 종단도 · 테이블을 덮지 않는다.
|
||||
높이는 위 경계 리사이저가 --b05-masshaul-height로 조절한다. */
|
||||
.b05-profile__masshaul-overlay {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
height: var(--b05-masshaul-height, 280px);
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: 0 -4px 12px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
/* 머리줄 — 펼침에선 곡선 위 여백(padTop)에 떠 자리를 안 먹고, 접으면 판에 머리줄만 남는다. */
|
||||
.b05-profile__masshaul-head {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 0;
|
||||
left: var(--spacing-8);
|
||||
/* 고정 Y축(z5 · 불투명) 위 — 아래면 [창 분할]이 축 띠에 가려짐 */
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-head > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > :not(.b05-profile__masshaul-head) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > .b05-profile__masshaul-head {
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 2px var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 종단 패널을 접으면 판도 함께 숨는다(배수유역도와 같음). */
|
||||
.b05-route-profile.is-collapsed .b05-profile__masshaul-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 공용 리사이저는 절대배치라 오버레이 위 경계에 그대로 앉는다. */
|
||||
@@ -142,13 +185,12 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 2차 슬라이드 손잡이 — 다른 패널들과 **같은 표준 삼각형 손잡이**를 그대로 쓴다
|
||||
(2026-08-04 사용자 지시). 패널 본문 바닥에 붙고, 오버레이(z-index 5) 위에 뜬다.
|
||||
접혔을 때 테이블 손잡이와 **일렬**로 놓이도록 중앙에서 우측으로 비켜 세운다
|
||||
(2026-08-05 사용자 지시 — 좌: 테이블, 우: 유토곡선). */
|
||||
/* 접기 손잡이 — 다른 패널들과 **같은 표준 삼각형 손잡이**를 그대로 쓴다
|
||||
(2026-08-04 사용자 지시). 판 머리줄에 앉아 접어도 닿는다(354-1). 테이블 손잡이와
|
||||
겹치지 않게 중앙에서 우측으로 비켜 세운다(2026-08-05 사용자 지시 — 좌: 테이블, 우: 유토곡선). */
|
||||
.b05-profile__masshaul-handle {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
left: calc(50% + 44px);
|
||||
z-index: 6;
|
||||
transform: translateX(-50%);
|
||||
@@ -163,8 +205,7 @@
|
||||
}
|
||||
|
||||
/* ── 테이블 오버레이 서브패널 (2026-08-05 사용자 지시) ──────────────────────
|
||||
유토곡선과 같은 뼈대. 유토곡선이 열려 있으면 그 위에 쌓인다(bottom은 JS가 조절).
|
||||
위→아래 순서: 종단도 → 테이블 → 유토곡선. */
|
||||
종단 칸 바닥에 붙어 종단도 위를 덮는다. 유토곡선은 그 아래 자기 판(354-1). */
|
||||
.b05-profile__table-overlay {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
|
||||
@@ -1,59 +1,35 @@
|
||||
/* 계획노선 편집(평면) — 종단 보기 가운데 위 본문(옛 3D 자리 · 345-3). 덮개 · 전체 화면은 걷음 —
|
||||
재계산 대기 막만 화면을 덮는다(노선 재탐색 · CLAUDE.md 5장). 색은 전부 테마 토큰을 쓴다. */
|
||||
|
||||
/* 평면 = 가운데 칸 전체(352-1) — 머리 줄 · 테두리 없음. 도구줄은 3D 와 같은 떠 있는 유리 단추
|
||||
(`.b05-route__view-controls` · ☰ 비킴 `--wf-burger-pad`) · 횡단 판은 오른쪽을 덮는 판. */
|
||||
.b05-routeedit {
|
||||
--b05-side-open: 0px; /* 횡단 판이 펴져 지도를 덮는 폭 */
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 0;
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 메인 창은 **왼쪽**, 횡단 두 판은 오른쪽 세로 칸(2026-09-12 사용자 지시 ⑱).
|
||||
좁은 화면에서는 오른쪽 칸이 접히고 메인이 폭을 다 가진다. */
|
||||
.b05-routeedit__box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-raised);
|
||||
.b05-routeedit.is-side-open {
|
||||
--b05-side-open: 460px;
|
||||
}
|
||||
|
||||
/* 머리 = 도구줄 — 왼쪽 이름 · 오른쪽 단추 묶음. */
|
||||
.b05-routeedit__head {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: var(--spacing-12);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b05-routeedit__head > h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
/* 제목행 — 왼쪽에 이름, **오른쪽 끝에 단추 묶음**(2026-09-12 사용자 지시 ⑪).
|
||||
단추는 공용 규격(`.ui-btn`)을 그대로 쓴다 — 모달이 따로 만든 크기를 걷어냈다. */
|
||||
.b05-routeedit__actions {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
/* 도구줄 — 3D 도구줄 규격 그대로 · 오른쪽 회전 단추 · 횡단 판을 비켜 줄바꿈. */
|
||||
.b05-routeedit__tools {
|
||||
right: calc(var(--spacing-12) + 112px); /* 오른쪽 위 회전 단추 자리 */
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
pointer-events: none; /* 빈 자리는 지도 조작을 통과 */
|
||||
}
|
||||
|
||||
.b05-routeedit__tools > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.b05-routeedit__canvas-wrap {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface);
|
||||
@@ -86,10 +62,10 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 조작 설명 — 지도 왼쪽 위 · 기본 접힘 · [?] 로 펼침(345-5 덧 3) · 펼치면 **2열**(사용자 지시 ⑩). */
|
||||
/* 조작 설명 — 도구줄 아래 왼쪽 · 기본 접힘 · [?] 로 펼침(345-5 덧 3 · 352-1) · 펼치면 **2열**(사용자 지시 ⑩). */
|
||||
.b05-routeedit__hint {
|
||||
top: var(--spacing-12);
|
||||
left: var(--spacing-12);
|
||||
top: calc(var(--spacing-16) + 34px + var(--spacing-8));
|
||||
left: var(--spacing-16);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-8);
|
||||
@@ -173,10 +149,10 @@
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
/* 회전 단추 — 지도 오른쪽 위(사용자 지시 ⑯). 여기만 클릭을 받는다. */
|
||||
/* 회전 단추 — 지도 오른쪽 위(사용자 지시 ⑯) · 횡단 판 왼쪽에 앉음(352-1). 여기만 클릭을 받는다. */
|
||||
.b05-routeedit__spin {
|
||||
position: absolute;
|
||||
top: var(--spacing-12);
|
||||
top: var(--spacing-16);
|
||||
right: var(--spacing-12);
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
@@ -211,18 +187,34 @@
|
||||
border-top: 2px solid var(--map-route, #f97316);
|
||||
}
|
||||
|
||||
/* 오른쪽 세로 칸 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다. 접는 칸(345-3) —
|
||||
좁은 화면은 접힌 채 시작(`_Chrome`) · [횡단] 단추로 펴고 접는다. */
|
||||
/* 오른쪽 덮는 판 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다(352-1). 지도 위에 얹고
|
||||
아래 종단 판(z 3)이 그 위를 덮는다. 접는 칸(345-3) — 좁은 화면은 접힌 채 시작(`_Chrome`) ·
|
||||
[횡단] 단추로 펴고 접는다. 빈 자리는 지도 조작을 통과. */
|
||||
.b05-routeedit__side-wrap {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: calc(var(--spacing-16) + 34px + var(--spacing-8)); /* 도구줄 · 회전 단추 줄 아래 */
|
||||
right: var(--spacing-12);
|
||||
bottom: var(--spacing-12);
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b05-routeedit__side-wrap.is-collapsed {
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.b05-routeedit__side-wrap > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.b05-routeedit__side-toggle {
|
||||
align-self: flex-end;
|
||||
min-width: 56px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.b05-routeedit__side {
|
||||
@@ -323,21 +315,20 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ㉓ 거리 재기와 되돌리기 사이 구분선. */
|
||||
.b05-routeedit__divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
margin: 0 var(--spacing-4, 4px);
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임. */
|
||||
/* 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임 · 글에 유리 바탕(352-1). */
|
||||
.b05-routeedit__terrain {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4, 4px);
|
||||
height: 34px;
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent);
|
||||
border-radius: var(--radius-8, 6px);
|
||||
background: color-mix(in srgb, var(--color-surface-raised) 72%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted, inherit);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.b05-routeedit__terrain[hidden] {
|
||||
@@ -347,7 +338,7 @@
|
||||
/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창(뜬 종단 판이 덮으면 가려진 채 · 349-0). 닫으면 잰 것이 지워진다. */
|
||||
.b05-routeedit__measure {
|
||||
position: absolute;
|
||||
right: var(--spacing-12);
|
||||
right: calc(var(--spacing-12) + var(--b05-side-open));
|
||||
bottom: var(--spacing-12);
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
@@ -461,11 +452,3 @@
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* 「거리 재기」가 켜진 동안 — 단추가 눌린 꼴로 남는다(2026-09-12 사용자 지시 ⑥).
|
||||
`is-active` 는 예전에도 붙고 있었으나 꼴이 없어 겉모습이 그대로였다. */
|
||||
.b05-routeedit__actions .ui-btn.is-active {
|
||||
border-color: transparent;
|
||||
background: var(--color-primary, #7c3aed);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/* 평면 등고선 자물쇠(353-2) — 커서 옆에 뜨는 작은 단추 · 자리는 `_ContourLock.ts` 가 잡음. */
|
||||
.b05-contourlock {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.b05-contourlock[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 풀림 = 고리가 들림 · 잠김 = 고른 등고선 색(보라)으로 채움 */
|
||||
.b05-contourlock[aria-pressed="false"] .b05-contourlock__shackle {
|
||||
transform: translate(2.5px, -1px) rotate(18deg);
|
||||
transform-origin: 11px 7px;
|
||||
}
|
||||
|
||||
.b05-contourlock[aria-pressed="true"] {
|
||||
border-color: var(--map-flow-arrow);
|
||||
background-color: var(--map-flow-arrow);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { createTerrainCompass } from "@ui/ui_template_compass";
|
||||
import { contourMaterials, isMajorContour } from "@ui/ui_template_contour3d";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -339,21 +340,20 @@ export function createRouteViewer(): RouteViewer {
|
||||
([x, y, z]) => new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)),
|
||||
);
|
||||
if (points.length < 2) return;
|
||||
const bucket = contour.level % (interval * 5) === 0 ? majorPoints : minorPoints;
|
||||
const bucket = isMajorContour(contour.level) ? majorPoints : minorPoints;
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
bucket.push(points[index], points[index + 1]);
|
||||
}
|
||||
});
|
||||
// 주곡선 = 5 m 고정 · 1 m 는 옅게(평면 그림과 같은 색 토큰 · 353-3)
|
||||
const materials = contourMaterials();
|
||||
[
|
||||
{ points: minorPoints, color: 0xf59e0b },
|
||||
{ points: majorPoints, color: 0xd97706 },
|
||||
].forEach(({ points, color }) => {
|
||||
{ points: minorPoints, material: materials.minor },
|
||||
{ points: majorPoints, material: materials.major },
|
||||
].forEach(({ points, material }) => {
|
||||
if (points.length === 0) return;
|
||||
contours.add(
|
||||
new THREE.LineSegments(
|
||||
new THREE.BufferGeometry().setFromPoints(points),
|
||||
new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.75 }),
|
||||
),
|
||||
new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(points), material),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_ContourLock.ts
|
||||
* 평면 등고선 고정(353-2) — 고른 높이 하나를 쥐고, 잠긴 동안 다른 지정을 막는 규칙.
|
||||
*
|
||||
* 높이값으로 쥔다(가닥 번호는 지형을 바꾸면 달라짐) · 페이지 동안만(저장 안 함).
|
||||
* 순수 계산 — 화면 · three · fetch · sessionStorage 없음. 단추는 `B05_Profile_UI_RouteEdit_ContourLock`.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 가닥마다 높이(모르면 null). */
|
||||
export type ContourLevels = ReadonlyArray<number | null>;
|
||||
|
||||
/** 자물쇠 단추 — 보일지 · 눌린 모양인지. */
|
||||
export interface LockButtonState {
|
||||
visible: boolean;
|
||||
pressed: boolean;
|
||||
}
|
||||
|
||||
/** 그 높이의 첫 가닥 번호 — 없으면 -1(지형을 바꾼 뒤 잠근 높이 다시 짚기). */
|
||||
export function featureOfLevel(levels: ContourLevels, level: number | null): number {
|
||||
if (level === null) return -1;
|
||||
return levels.findIndex((value) => value === level);
|
||||
}
|
||||
|
||||
/** 커서 아래 가닥 높이 · 잠근 높이 → 단추 — 안 잠김 = 높이 있는 가닥 위에서 · 잠김 = 그 높이 위에서만. */
|
||||
export function lockButtonState(
|
||||
hoverLevel: number | null,
|
||||
lockedLevel: number | null,
|
||||
): LockButtonState {
|
||||
if (lockedLevel !== null) return { visible: hoverLevel === lockedLevel, pressed: true };
|
||||
return { visible: hoverLevel !== null, pressed: false };
|
||||
}
|
||||
|
||||
/** 단추 누름 — 잠김이면 풂(null) · 아니면 그 가닥 높이로 잠금. */
|
||||
export function toggleLock(lockedLevel: number | null, targetLevel: number | null): number | null {
|
||||
return lockedLevel !== null ? null : targetLevel;
|
||||
}
|
||||
|
||||
/** 잠긴 동안은 고른 가닥이 안 바뀜(다른 가닥 · 빈 곳 눌러도). */
|
||||
export const canPickContour = (lockedLevel: number | null): boolean => lockedLevel === null;
|
||||
@@ -3,7 +3,8 @@
|
||||
* 평면 발자국 — 측점 횡단 설계 결과를 평면 선으로(도로 끝 좌 · 우 · 비탈 끝 깎기 · 쌓기 · 눈금) (346-2)
|
||||
*
|
||||
* ⚠⚠ 짝: `B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py`
|
||||
* (`route_frame` · `side_is_cut` · `slope_end` · `slope_pieces`) — 한쪽만 고치면
|
||||
* (`route_frame` · `route_normal` · `side_is_cut` · `slope_end` · `slope_pieces` 와 그 아래
|
||||
* 단조 3차 보간 · 촘촘히 · 접힘 버림 · 353-1) — 한쪽만 고치면
|
||||
* 종단 · 횡단 편집 중 평면과 B07 계획평면도가 갈린다.
|
||||
* `footprintRecord` = `B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄.
|
||||
* 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py`
|
||||
@@ -105,18 +106,6 @@ export function routeFrame(route: Array<[number, number, number]>, chainage: num
|
||||
return [x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length];
|
||||
}
|
||||
|
||||
function interp(records: Array<Record<string, number>>, chainage: number, key: string): number {
|
||||
const chainages = records.map((record) => record.chainage_m);
|
||||
const index = bisectLeft(chainages, chainage);
|
||||
if (index <= 0) return records[0][key];
|
||||
if (index >= records.length) return records[records.length - 1][key];
|
||||
const before = records[index - 1];
|
||||
const after = records[index];
|
||||
const span = after.chainage_m - before.chainage_m;
|
||||
const ratio = span > 0 ? (chainage - before.chainage_m) / span : 0.0;
|
||||
return before[key] + (after[key] - before[key]) * ratio;
|
||||
}
|
||||
|
||||
function groundOf(samples: FootprintPoint[]): Array<[number, number]> {
|
||||
return samples
|
||||
.filter(
|
||||
@@ -216,13 +205,187 @@ function splitRuns(points: Line, flags: boolean[]): [Line[], Line[]] {
|
||||
return [runs.true.filter((run) => run.length >= 2), runs.false.filter((run) => run.length >= 2)];
|
||||
}
|
||||
|
||||
/** 절/성토선 매끄럽게(353-1) — 네 선 중 하나라도 사이 가운데 점에서 이보다 꺾이면 반으로 나눔(°). 짝 같은 이름. */
|
||||
export const SPLIT_TURN_DEG = 2.0;
|
||||
/** 나눔 깊이 상한(2 m → 12.5 cm). */
|
||||
export const SPLIT_DEPTH = 4;
|
||||
/** 사이 점 법선을 재는 앞뒤 거리(m) — 측점 횡단 틀(`stationFrameAt`)과 같은 값. */
|
||||
export const NORMAL_PROBE_M = 1.0;
|
||||
|
||||
type Frame = [number, number, number, number];
|
||||
|
||||
/** 노선 위 그 자리와 좌측 법선 — 법선은 앞뒤 ±1 m 두 점을 잇는 방향. 짝: `route_normal`. */
|
||||
export function routeNormal(route: Array<[number, number, number]>, chainage: number): Frame {
|
||||
const [x, y, nx, ny] = routeFrame(route, chainage);
|
||||
const first = route[0][0];
|
||||
const last = route[route.length - 1][0];
|
||||
let before = Math.max(first, chainage - NORMAL_PROBE_M);
|
||||
let after = Math.min(last, chainage + NORMAL_PROBE_M);
|
||||
if (after - before <= 1e-9) {
|
||||
before = Math.max(first, chainage - 1e-3);
|
||||
after = Math.min(last, chainage + 1e-3);
|
||||
}
|
||||
const [x0, y0] = routeFrame(route, before);
|
||||
const [x1, y1] = routeFrame(route, after);
|
||||
const length = Math.hypot(x1 - x0, y1 - y0);
|
||||
if (length <= 1e-9) return [x, y, nx, ny];
|
||||
return [x, y, -(y1 - y0) / length, (x1 - x0) / length];
|
||||
}
|
||||
|
||||
function bisectRight(values: number[], target: number): number {
|
||||
let low = 0;
|
||||
let high = values.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >> 1;
|
||||
if (target < values[middle]) high = middle;
|
||||
else low = middle + 1;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
type Knots = [number[], number[], number[]];
|
||||
|
||||
/** 단조 3차(Fritsch–Carlson) 꺾임점 기울기 — 이웃 값 사이를 넘지 않음. 짝: `_pchip_slopes`. */
|
||||
function pchipSlopes(xs: number[], ys: number[]): number[] {
|
||||
const n = xs.length;
|
||||
if (n < 2) return xs.map(() => 0);
|
||||
const h = xs.slice(1).map((x, i) => x - xs[i]);
|
||||
const delta = h.map((span, i) => (ys[i + 1] - ys[i]) / span);
|
||||
const slopes = [delta[0], ...new Array<number>(n - 2).fill(0), delta[n - 2]];
|
||||
for (let k = 1; k < n - 1; k += 1) {
|
||||
if (delta[k - 1] * delta[k] > 0) {
|
||||
const w1 = 2 * h[k] + h[k - 1];
|
||||
const w2 = h[k] + 2 * h[k - 1];
|
||||
slopes[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k]);
|
||||
}
|
||||
}
|
||||
return slopes;
|
||||
}
|
||||
|
||||
/** 꺾임점 값을 그대로 지나는 단조 3차 보간 — 범위 밖은 끝값. 짝: `_pchip`. */
|
||||
function pchip([xs, ys, slopes]: Knots, x: number): number {
|
||||
if (x <= xs[0]) return ys[0];
|
||||
if (x >= xs[xs.length - 1]) return ys[ys.length - 1];
|
||||
const i = bisectRight(xs, x) - 1;
|
||||
const h = xs[i + 1] - xs[i];
|
||||
const t = (x - xs[i]) / h;
|
||||
return (
|
||||
(1 + 2 * t) * (1 - t) ** 2 * ys[i] +
|
||||
t * (1 - t) ** 2 * h * slopes[i] +
|
||||
t * t * (3 - 2 * t) * ys[i + 1] +
|
||||
t * t * (t - 1) * h * slopes[i + 1]
|
||||
);
|
||||
}
|
||||
|
||||
const knots = (xs: number[], ys: number[]): Knots => [xs, ys, pchipSlopes(xs, ys)];
|
||||
|
||||
interface SideCurves {
|
||||
chainages: number[];
|
||||
flags: boolean[];
|
||||
switches: number[];
|
||||
edge: Knots;
|
||||
width: Knots;
|
||||
}
|
||||
|
||||
/** 한 쪽 도로 끝 · 비탈 폭 보간 + 깎기↔쌓기 바뀜 자리(폭 0 = 비탈 끝이 도로 끝으로 모임). 짝: `_side_curves`. */
|
||||
function sideCurves(records: FootprintRecord[], side: Side): SideCurves {
|
||||
const chainages = records.map((record) => record.chainage_m);
|
||||
const edges = records.map((record) => record[`${side}_edge_m`]);
|
||||
const widths = records.map((record) => record[`${side}_end_m`] - record[`${side}_edge_m`]);
|
||||
const flags = records.map((record) => Boolean(record[`${side}_cut`]));
|
||||
const switches: number[] = [];
|
||||
const widthX = [chainages[0]];
|
||||
const widthY = [widths[0]];
|
||||
for (let i = 1; i < records.length; i += 1) {
|
||||
const c0 = chainages[i - 1];
|
||||
const c1 = chainages[i];
|
||||
const w0 = Math.abs(widths[i - 1]);
|
||||
const w1 = Math.abs(widths[i]);
|
||||
const at = c0 + (c1 - c0) * (w0 + w1 > 1e-9 ? w0 / (w0 + w1) : 0.5);
|
||||
switches.push(at);
|
||||
if (flags[i - 1] !== flags[i] && c0 + 1e-9 < at && at < c1 - 1e-9) {
|
||||
widthX.push(at);
|
||||
widthY.push(0.0);
|
||||
}
|
||||
widthX.push(c1);
|
||||
widthY.push(widths[i]);
|
||||
}
|
||||
return {
|
||||
chainages,
|
||||
flags,
|
||||
switches,
|
||||
edge: knots(chainages, edges),
|
||||
width: knots(widthX, widthY),
|
||||
};
|
||||
}
|
||||
|
||||
/** 그 자리 깎기 여부 — 바뀜 자리부터 뒤 측점 것(바뀌는 점은 앞뒤 조각이 나눠 가짐). 짝: `_cut_at`. */
|
||||
function cutAt(curves: SideCurves, chainage: number): boolean {
|
||||
const { flags } = curves;
|
||||
const i = bisectRight(curves.chainages, chainage);
|
||||
if (i === 0) return flags[0];
|
||||
if (i === flags.length) return flags[flags.length - 1];
|
||||
if (flags[i - 1] === flags[i]) return flags[i];
|
||||
return chainage < curves.switches[i - 1] ? flags[i - 1] : flags[i];
|
||||
}
|
||||
|
||||
/** 진행을 거스르는 점(안쪽 비탈 끝이 곡선 중심을 넘어 접힘)을 버린 남는 점 번호. 짝: `_forward_only`. */
|
||||
function forwardOnly(points: Line, frames: Frame[]): number[] {
|
||||
const kept = points.length ? [0] : [];
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
const last = points[kept[kept.length - 1]];
|
||||
const [, , nx, ny] = frames[i];
|
||||
if ((points[i][0] - last[0]) * ny - (points[i][1] - last[1]) * nx > 1e-6) kept.push(i);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
function turnDeg(a: number[], m: number[], b: number[]): number {
|
||||
const u = [m[0] - a[0], m[1] - a[1]];
|
||||
const v = [b[0] - m[0], b[1] - m[1]];
|
||||
return Math.abs(
|
||||
(Math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]) * 180) / Math.PI,
|
||||
);
|
||||
}
|
||||
|
||||
/** 곡선 · 폭이 가파르게 바뀌는 자리 — 가운데 점에서 많이 꺾이는 사이를 반씩 나눔. 짝: `_densify`. */
|
||||
function densify(
|
||||
chainages: number[],
|
||||
frameOf: (chainage: number) => Frame,
|
||||
place: (chainage: number, frame: Frame) => Line,
|
||||
): [number[], Frame[]] {
|
||||
const split = (c0: number, p0: Line, c1: number, p1: Line, depth: number): number[] => {
|
||||
if (depth >= SPLIT_DEPTH) return [];
|
||||
const middle = (c0 + c1) / 2.0;
|
||||
const pm = place(middle, frameOf(middle));
|
||||
// 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음
|
||||
const bends = pm.some((m, k) => {
|
||||
const turn = turnDeg(p0[k], m, p1[k]);
|
||||
return SPLIT_TURN_DEG < turn && turn < 90.0;
|
||||
});
|
||||
if (!bends) return [];
|
||||
return [
|
||||
...split(c0, p0, middle, pm, depth + 1),
|
||||
middle,
|
||||
...split(middle, pm, c1, p1, depth + 1),
|
||||
];
|
||||
};
|
||||
const points = chainages.map((chainage) => place(chainage, frameOf(chainage)));
|
||||
const out = chainages.slice(0, 1);
|
||||
for (let i = 1; i < chainages.length; i += 1) {
|
||||
out.push(...split(chainages[i - 1], points[i - 1], chainages[i], points[i], 0));
|
||||
out.push(chainages[i]);
|
||||
}
|
||||
return [out, out.map(frameOf)];
|
||||
}
|
||||
|
||||
interface Sampled {
|
||||
lines: Record<(typeof KEYS)[number], Line>;
|
||||
cut: Record<Side, boolean[]>;
|
||||
ticks: Line[];
|
||||
}
|
||||
|
||||
/** 측점 줄 → 측점 · 눈금 간격마다 네 선 · 쪽마다 깎기 여부 · 눈금. `slope_pieces` 의 몸통. */
|
||||
/** 측점 줄 → 네 선 · 쪽마다 깎기 여부 · 눈금. `slope_pieces` 의 몸통. */
|
||||
function sampleLines(
|
||||
route: Array<[number, number, number]>,
|
||||
input: FootprintRecord[],
|
||||
@@ -230,49 +393,94 @@ function sampleLines(
|
||||
endM: number,
|
||||
stepM: number,
|
||||
): Sampled | null {
|
||||
const records = input
|
||||
const sorted = input
|
||||
.map((record) => ({
|
||||
...record,
|
||||
left_cut: Number(Boolean(record.left_cut)),
|
||||
right_cut: Number(Boolean(record.right_cut)),
|
||||
left_cut: Boolean(record.left_cut),
|
||||
right_cut: Boolean(record.right_cut),
|
||||
}))
|
||||
.sort((a, b) => a.chainage_m - b.chainage_m) as unknown as Array<Record<string, number>>;
|
||||
if (route.length < 2 || !records.length) return null;
|
||||
const byChainage = new Map<number, Record<string, number>>();
|
||||
.sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
if (route.length < 2 || !sorted.length) return null;
|
||||
// 같은 누가거리 측점은 뒤 것 하나만
|
||||
const records: FootprintRecord[] = [];
|
||||
for (const record of sorted) {
|
||||
const last = records[records.length - 1];
|
||||
if (last && record.chainage_m - last.chainage_m <= 1e-9) records[records.length - 1] = record;
|
||||
else records.push(record);
|
||||
}
|
||||
const curves = { left: sideCurves(records, "left"), right: sideCurves(records, "right") };
|
||||
const byChainage = new Map<number, FootprintRecord>();
|
||||
for (const record of records) byChainage.set(roundTo(record.chainage_m, 3), record);
|
||||
const samples = new Set<number>();
|
||||
const base = new Set<number>();
|
||||
for (const record of records) {
|
||||
if (startM <= record.chainage_m && record.chainage_m <= endM)
|
||||
samples.add(roundTo(record.chainage_m, 3));
|
||||
base.add(roundTo(record.chainage_m, 3));
|
||||
}
|
||||
for (let step = startM; step <= endM; step += stepM) samples.add(roundTo(step, 3));
|
||||
const lines = Object.fromEntries(KEYS.map((key) => [key, []])) as unknown as Sampled["lines"];
|
||||
const cut: Record<Side, boolean[]> = { left: [], right: [] };
|
||||
for (let step = startM; step <= endM; step += stepM) base.add(roundTo(step, 3));
|
||||
const all = new Set(base);
|
||||
for (const side of SIDES) {
|
||||
const { flags, switches } = curves[side];
|
||||
switches.forEach((at, i) => {
|
||||
if (flags[i] !== flags[i + 1] && startM <= at && at <= endM) all.add(at);
|
||||
});
|
||||
}
|
||||
const frameOf = (chainage: number): Frame => {
|
||||
const record = byChainage.get(roundTo(chainage, 3));
|
||||
const { x, y, nx, ny } = record ?? {};
|
||||
if (x !== undefined && y !== undefined && nx !== undefined && ny !== undefined)
|
||||
return [x, y, nx, ny];
|
||||
return routeNormal(route, chainage);
|
||||
};
|
||||
const valuesAt = (chainage: number): Record<string, number> => {
|
||||
const values: Record<string, number> = {};
|
||||
for (const side of SIDES) {
|
||||
const edge = pchip(curves[side].edge, chainage);
|
||||
values[`${side}_edge_m`] = edge;
|
||||
values[`${side}_end_m`] = edge + pchip(curves[side].width, chainage);
|
||||
}
|
||||
return values;
|
||||
};
|
||||
const place = (chainage: number, [x, y, nx, ny]: Frame): Line => {
|
||||
const values = valuesAt(chainage);
|
||||
return KEYS.map((key) => [x + nx * values[key], y + ny * values[key]]);
|
||||
};
|
||||
const [chainages, frames] = densify(
|
||||
[...all].sort((a, b) => a - b),
|
||||
frameOf,
|
||||
place,
|
||||
);
|
||||
const offsets = chainages.map(valuesAt);
|
||||
const raw = Object.fromEntries(KEYS.map((key) => [key, []])) as unknown as Sampled["lines"];
|
||||
frames.forEach(([x, y, nx, ny], i) => {
|
||||
for (const key of KEYS)
|
||||
raw[key].push([roundTo(x + nx * offsets[i][key], 4), roundTo(y + ny * offsets[i][key], 4)]);
|
||||
});
|
||||
const kept = Object.fromEntries(
|
||||
KEYS.map((key) => [key, forwardOnly(raw[key], frames)]),
|
||||
) as unknown as Record<(typeof KEYS)[number], number[]>;
|
||||
const lines = Object.fromEntries(
|
||||
KEYS.map((key) => [key, kept[key].map((i) => raw[key][i])]),
|
||||
) as unknown as Sampled["lines"];
|
||||
const cut: Record<Side, boolean[]> = {
|
||||
left: kept.left_end_m.map((i) => cutAt(curves.left, chainages[i])),
|
||||
right: kept.right_end_m.map((i) => cutAt(curves.right, chainages[i])),
|
||||
};
|
||||
// 눈금은 도로 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아(납품
|
||||
// 도면 관례) · 접혀 버린 자리는 뺌.
|
||||
const ticks: Line[] = [];
|
||||
[...samples]
|
||||
const keptSets = { left: new Set(kept.left_end_m), right: new Set(kept.right_end_m) };
|
||||
const position = new Map(chainages.map((chainage, i) => [chainage, i]));
|
||||
[...base]
|
||||
.sort((a, b) => a - b)
|
||||
.forEach((chainage, index) => {
|
||||
const record = byChainage.get(chainage);
|
||||
const [x, y, nx, ny] =
|
||||
record && ["x", "y", "nx", "ny"].every((k) => record[k] !== undefined)
|
||||
? [record.x, record.y, record.nx, record.ny]
|
||||
: routeFrame(route, chainage);
|
||||
const offsets = Object.fromEntries(KEYS.map((key) => [key, interp(records, chainage, key)]));
|
||||
const points = Object.fromEntries(
|
||||
KEYS.map((key) => [
|
||||
key,
|
||||
[roundTo(x + nx * offsets[key], 4), roundTo(y + ny * offsets[key], 4)],
|
||||
]),
|
||||
);
|
||||
for (const key of KEYS) lines[key].push(points[key]);
|
||||
for (const side of SIDES) cut[side].push(interp(records, chainage, `${side}_cut`) > 0.5);
|
||||
// 눈금은 도로 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아(납품 도면 관례).
|
||||
const i = position.get(chainage) ?? 0;
|
||||
const [x, y, nx, ny] = frames[i];
|
||||
for (const side of SIDES) {
|
||||
const edge = offsets[`${side}_edge_m`];
|
||||
const end = offsets[`${side}_end_m`];
|
||||
if (Math.abs(end - edge) < 0.05) continue;
|
||||
const edge = offsets[i][`${side}_edge_m`];
|
||||
const end = offsets[i][`${side}_end_m`];
|
||||
if (Math.abs(end - edge) < 0.05 || !keptSets[side].has(i)) continue;
|
||||
const reach = index % 2 === 0 ? end : edge + (end - edge) / 2.0;
|
||||
ticks.push([points[`${side}_edge_m`], [x + nx * reach, y + ny * reach]]);
|
||||
ticks.push([raw[`${side}_edge_m`][i], [x + nx * reach, y + ny * reach]]);
|
||||
}
|
||||
});
|
||||
return { lines, cut, ticks };
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_RouteShift.ts
|
||||
* 횡단 중심점 좌우 이동(356-3) — 측점 자리 노선을 좌측 축으로 Δ m 옮길 노드 편집을 고르고 · 그 측점
|
||||
* 계획고를 옮기기 전 값으로 되돌릴 계획고 편집을 냄.
|
||||
*
|
||||
* · P = 측점 중심 · L = 좌측 단위 벡터(`stationFrameAt` · +offset = 좌)
|
||||
* · P 에서 0.5 m 안 노드 = 그 노드 + Δ·L(고정 노드면 안 옮김)
|
||||
* · 곡선(BC ~ EC) 안 측점 = 그 교각점 + Δ·L(고정이면 안 옮김)
|
||||
* · 둘 다 아니면 P + Δ·L 을 그 자리 노드 토막에 끼움
|
||||
* · 높이 = 옮기기 전 계획고 − 옮긴 뒤 계획고 만큼 그 측점 계획고 편집(`adjustStation`)
|
||||
*
|
||||
* 브라우저가 고르는 편집값일 뿐 — 서버는 [저장] 때 노드 · 계획고 편집을 받아 정본으로 다시 셈(짝 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
adjustStation,
|
||||
buildAlignment,
|
||||
planElevationAt,
|
||||
type AlignmentBase,
|
||||
type AlignmentEdits,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { curveSpans, stationFrameAt, type CurveEnds } from "./B05_Profile_UI_RouteEdit_Frame";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
/** 측점 중심에서 이 안의 노드는 끼우지 않고 옮김(m). */
|
||||
export const NODE_SNAP_M = 0.5;
|
||||
/** 이보다 작은 높이 차는 보정 안 함(m). */
|
||||
export const PIN_TOLERANCE_M = 5e-4;
|
||||
|
||||
export type RouteShiftPlan =
|
||||
| { kind: "move"; index: number; to: Vertex }
|
||||
| { kind: "insert"; index: number; at: Vertex }
|
||||
| { kind: "locked"; index: number };
|
||||
|
||||
export interface RouteShiftInput {
|
||||
/** 노드(꺾임점). */
|
||||
planned: ReadonlyArray<Vertex>;
|
||||
/** 그려진 계획노선(원호 펴진 폴리라인) — 측점 누가거리의 기준. */
|
||||
line: ReadonlyArray<Vertex>;
|
||||
/** 곡선표 — `node_first` = 그 곡선 교각점 노드 자리. */
|
||||
curves: ReadonlyArray<CurveEnds & { node_first: number }>;
|
||||
/** 노드마다 고정. */
|
||||
locked: ReadonlyArray<boolean>;
|
||||
chainageM: number;
|
||||
/** 좌(+) · 우(−) 이동(m). */
|
||||
deltaM: number;
|
||||
}
|
||||
|
||||
/** 점에서 가장 가까운 노드 토막 차례(앞 노드 자리). */
|
||||
function nearestSegment(nodes: ReadonlyArray<Vertex>, point: Vertex): number {
|
||||
let best = 0;
|
||||
let bestGap = Infinity;
|
||||
for (let i = 0; i < nodes.length - 1; i += 1) {
|
||||
const [ax, ay] = nodes[i];
|
||||
const [vx, vy] = [nodes[i + 1][0] - ax, nodes[i + 1][1] - ay];
|
||||
const length2 = vx * vx + vy * vy || 1;
|
||||
const t = Math.min(1, Math.max(0, ((point[0] - ax) * vx + (point[1] - ay) * vy) / length2));
|
||||
const gap = Math.hypot(ax + vx * t - point[0], ay + vy * t - point[1]);
|
||||
if (gap < bestGap) [best, bestGap] = [i, gap];
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 노드 편집 고름 — 노선이 없으면 null. */
|
||||
export function planRouteShift(input: RouteShiftInput): RouteShiftPlan | null {
|
||||
const { planned, line, curves, locked, chainageM, deltaM } = input;
|
||||
const frame = planned.length >= 2 ? stationFrameAt(line, chainageM) : null;
|
||||
if (!frame) return null;
|
||||
const { origin, left } = frame;
|
||||
const shifted = ([x, y]: Vertex): Vertex => [x + left[0] * deltaM, y + left[1] * deltaM];
|
||||
const move = (index: number): RouteShiftPlan =>
|
||||
locked[index]
|
||||
? { kind: "locked", index }
|
||||
: { kind: "move", index, to: shifted(planned[index]) };
|
||||
const near = planned.findIndex(
|
||||
([x, y]) => Math.hypot(x - origin[0], y - origin[1]) <= NODE_SNAP_M,
|
||||
);
|
||||
if (near >= 0) return move(near);
|
||||
const curve = curves.find((one) => {
|
||||
const [span] = curveSpans(line, [one]);
|
||||
return span && chainageM > span[0] && chainageM < span[1];
|
||||
});
|
||||
if (curve && planned[curve.node_first]) return move(curve.node_first);
|
||||
return { kind: "insert", index: nearestSegment(planned, origin) + 1, at: shifted(origin) };
|
||||
}
|
||||
|
||||
/** 그 측점 계획고를 `zBefore` 로 되돌린 계획고 편집 — 차가 없으면 null. */
|
||||
export function pinStationHeight(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
chainageM: number,
|
||||
zBefore: number,
|
||||
): AlignmentEdits | null {
|
||||
const drift = zBefore - planElevationAt(buildAlignment(base, edits), chainageM);
|
||||
return Math.abs(drift) < PIN_TOLERANCE_M ? null : adjustStation(base, edits, chainageM, drift);
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
* - 편집 델타는 항상 **자동 선형(base_pvi) 기준**이라 키를 지우면 정확히 원복된다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pivotAxes } from "./B05_Profile_UI_Profile_Pivot";
|
||||
|
||||
export interface AlignmentPolicy {
|
||||
station_interval_m: number;
|
||||
curve_radius_ratio: number;
|
||||
@@ -552,6 +554,7 @@ const FEEDBACK_TOLERANCE_M = 1e-4;
|
||||
* 변화점이 새로 생기면 종단곡선의 중앙종거만큼 계획고가 함께 내려가(또는 올라가)
|
||||
* 버튼 1클릭이 정확히 0.1m가 되지 않는다. **화면에 보이는 계획고**가 정확히 delta만큼
|
||||
* 움직이도록 중앙종거 변화분을 몇 번 되먹여 보정한다.
|
||||
* 누른 점이 꺾임이면 이웃 꺾임(`pivotAxes`)을 축으로 사이 측점이 새 직선 위로 따라 돈다(355-1).
|
||||
*/
|
||||
export function adjustStation(
|
||||
base: AlignmentBase,
|
||||
@@ -562,17 +565,24 @@ export function adjustStation(
|
||||
const key = chainageKey(chainageM);
|
||||
const current = buildAlignment(base, edits);
|
||||
const target = planElevationAt(current, chainageM) + delta;
|
||||
const baseElevation = interpolate(
|
||||
base.basePvi.map((node) => node.chainage_m),
|
||||
base.basePvi.map((node) => node.elevation_m),
|
||||
chainageM,
|
||||
);
|
||||
const baseS = base.basePvi.map((node) => node.chainage_m);
|
||||
const baseZ = base.basePvi.map((node) => node.elevation_m);
|
||||
const baseElevation = interpolate(baseS, baseZ, chainageM);
|
||||
let offset =
|
||||
(edits.station_offsets[key] ?? planElevationAt(current, chainageM) - baseElevation) + delta;
|
||||
const withOffset = (value: number): AlignmentEdits => ({
|
||||
...edits,
|
||||
station_offsets: { ...edits.station_offsets, [key]: Number(value.toFixed(6)) },
|
||||
});
|
||||
const axes = pivotAxes(current, chainageM);
|
||||
const withOffset = (value: number): AlignmentEdits => {
|
||||
const offsets = { ...edits.station_offsets, [key]: Number(value.toFixed(6)) };
|
||||
const radii = { ...edits.curve_radii };
|
||||
axes?.inner.forEach((chainage) => {
|
||||
const [axisM, axisZ] = chainage < chainageM ? [axes.fromM, axes.fromZ] : [axes.toM, axes.toZ];
|
||||
const z =
|
||||
axisZ + ((baseElevation + value - axisZ) * (chainage - axisM)) / (chainageM - axisM);
|
||||
offsets[chainageKey(chainage)] = Number((z - interpolate(baseS, baseZ, chainage)).toFixed(6));
|
||||
delete radii[chainageKey(chainage)];
|
||||
});
|
||||
return { ...edits, station_offsets: offsets, curve_radii: radii };
|
||||
};
|
||||
let next = withOffset(offset);
|
||||
for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) {
|
||||
const error = target - planElevationAt(buildAlignment(base, next), chainageM);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Pivot.ts
|
||||
* 종단 ▲▼ 축 찾기(355-1 · 요구 235-15) — 누른 점이 꺾임(R 있는 점)이면 좌우로 같은
|
||||
* 기울기 마디를 건너 다음 꺾임(없으면 시점 · 종점)을 축으로 삼는다. 사이 측점은
|
||||
* 새 직선(축 → 누른 점 → 축) 위로 따라 돈다(`adjustStation`).
|
||||
*
|
||||
* 처음 계획선은 마디마다 꺾여 축 = 누른 점 ±1 → 사이 측점 없음(옛 동작 그대로).
|
||||
* `B05_Profile_UI_Profile_Alignment` 를 타입으로만 읽는다(순환 import 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
/** 같은 직선으로 볼 기울기 차이 — `B05_Profile_UI_Profile_Straighten` 과 같은 값. */
|
||||
const COLLINEAR_EPSILON = 1e-6;
|
||||
const SAME_STATION_M = 1e-6;
|
||||
|
||||
export interface PivotAxes {
|
||||
fromM: number;
|
||||
fromZ: number;
|
||||
toM: number;
|
||||
toZ: number;
|
||||
/** 두 축 사이(끝 · 누른 점 제외) 변화점 chainage. */
|
||||
inner: number[];
|
||||
}
|
||||
|
||||
/** 누른 점이 꺾임이면 두 축과 사이 변화점 — 직선 안쪽 · 시종점 · 변화점 아님이면 null. */
|
||||
export function pivotAxes(alignment: ProfileAlignment, chainageM: number): PivotAxes | null {
|
||||
const { pvi, segments } = alignment;
|
||||
const index = pvi.findIndex((node) => Math.abs(node.chainage_m - chainageM) < SAME_STATION_M);
|
||||
if (index <= 0 || index >= pvi.length - 1) return null;
|
||||
const grade = (at: number): number => {
|
||||
const segment = segments[at];
|
||||
return segment && segment.length_m > 1e-12 ? segment.height_m / segment.length_m : 0;
|
||||
};
|
||||
const same = (a: number, b: number): boolean => Math.abs(grade(a) - grade(b)) < COLLINEAR_EPSILON;
|
||||
if (same(index - 1, index)) return null;
|
||||
let from = index - 1;
|
||||
while (from > 0 && same(from - 1, index - 1)) from -= 1;
|
||||
let to = index + 1;
|
||||
while (to < pvi.length - 1 && same(to, index)) to += 1;
|
||||
return {
|
||||
fromM: pvi[from].chainage_m,
|
||||
fromZ: pvi[from].elevation_m,
|
||||
toM: pvi[to].chainage_m,
|
||||
toZ: pvi[to].elevation_m,
|
||||
inner: pvi
|
||||
.slice(from + 1, to)
|
||||
.map((node) => node.chainage_m)
|
||||
.filter((chainage) => Math.abs(chainage - chainageM) >= SAME_STATION_M),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user