Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
This commit is contained in:
@@ -335,9 +335,35 @@ export function createRouteProfilePanel(
|
||||
});
|
||||
}
|
||||
|
||||
/* 줌·Y레인지 조작구 — 상태를 페이지가 들고 있어 편집·재계산으로 다시 그려도 유지된다. */
|
||||
/* 줌 조작구(가로 배율) — 상태를 페이지가 들고 있어 편집·재계산으로 다시 그려도 유지된다.
|
||||
세로는 보이는 구간에 맞춰 자동이라 사람이 맞출 것이 없다(2026-09-04 사용자 확정). */
|
||||
const profileZoom = createProfileZoom(() => draw());
|
||||
|
||||
/** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */
|
||||
let elevationWindow: { min: number; max: number } | undefined;
|
||||
/** 계획고 편집 버튼(▲▼)을 누르고 있는 중인가 — 그동안 Y 축을 고정한다. */
|
||||
let heightEditing = false;
|
||||
const holdElevationRange = (
|
||||
next: { min: number; max: number } | null,
|
||||
): { min: number; max: number } | undefined => {
|
||||
if (heightEditing) return elevationWindow;
|
||||
elevationWindow = next ?? undefined;
|
||||
return elevationWindow;
|
||||
};
|
||||
// 끌어 올리는 동안 축까지 따라 움직이면 조작 감각이 깨진다 — 손을 뗀 뒤 한 번만 다시 맞춘다.
|
||||
body.addEventListener("pointerdown", (event) => {
|
||||
if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return;
|
||||
heightEditing = true;
|
||||
const release = (): void => {
|
||||
heightEditing = false;
|
||||
window.removeEventListener("pointerup", release);
|
||||
window.removeEventListener("pointercancel", release);
|
||||
draw();
|
||||
};
|
||||
window.addEventListener("pointerup", release);
|
||||
window.addEventListener("pointercancel", release);
|
||||
});
|
||||
|
||||
/* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 로 뺐다(700줄 한계). */
|
||||
const { tools, history, handleToolPick } = createPanelTools({
|
||||
root,
|
||||
@@ -474,6 +500,7 @@ export function createRouteProfilePanel(
|
||||
applyEdits,
|
||||
handleToolPick,
|
||||
zoom: profileZoom.state,
|
||||
holdElevationRange,
|
||||
toolActive: () => tools.mode() !== "none",
|
||||
selectedRuns: () => tools.selectedRuns(),
|
||||
stationIdAtStructure,
|
||||
@@ -481,6 +508,21 @@ export function createRouteProfilePanel(
|
||||
});
|
||||
}
|
||||
|
||||
/** 가로 스크롤이 이만큼 멈춰 있으면 보이는 구간이 정해진 것으로 보고 세로를 다시 맞춘다. */
|
||||
const SCROLL_SETTLE_MS = 160;
|
||||
/** 마지막으로 세로를 맞춘 가로 위치 — 같은 자리면 다시 그리지 않는다(재구성 되먹임 차단). */
|
||||
let settledScrollLeft = 0;
|
||||
let scrollSettleTimer = 0;
|
||||
// 스크롤하는 내내 축이 출렁이면 어지럽다 — 멈춘 뒤에 한 번만 다시 맞춘다(2026-09-04).
|
||||
body.addEventListener("scroll", () => {
|
||||
window.clearTimeout(scrollSettleTimer);
|
||||
scrollSettleTimer = window.setTimeout(() => {
|
||||
if (heightEditing || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
|
||||
settledScrollLeft = body.scrollLeft;
|
||||
draw();
|
||||
}, SCROLL_SETTLE_MS);
|
||||
});
|
||||
|
||||
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
|
||||
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
|
||||
body.addEventListener(
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
createLongitudinalProfile,
|
||||
longitudinalMinimumWidth,
|
||||
} from "../B06_Section/B06_Section_UI_Longitudinal";
|
||||
import { hasStaleDesigns, LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import {
|
||||
hasStaleDesigns,
|
||||
LONG_PAD,
|
||||
windowElevationRange,
|
||||
} from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data";
|
||||
import {
|
||||
@@ -84,8 +88,15 @@ export interface ProfileRenderContext {
|
||||
applyEdits: (next: AlignmentEdits) => void;
|
||||
/** [직선화]·[쉬프트] 도구가 그래프 클릭을 먼저 먹는지(먹었으면 기본 선택을 건너뛴다). */
|
||||
handleToolPick: (chainageM: number | null) => boolean;
|
||||
/** 가로 폭 배수·세로 표시 표고창(줌 조작구 상태) — 2026-09-04. */
|
||||
/** 가로 폭 배수(줌 조작구 상태) — 세로는 자동이라 배율이 없다(2026-09-04). */
|
||||
zoom: () => ProfileZoomState;
|
||||
/**
|
||||
* 세로 자동 맞춤의 Y 창을 넘겨 주고 **실제로 쓸 창**을 돌려받는다. 계획고를 끌어 올리는
|
||||
* 동안에는 본체가 직전 창을 붙잡아 돌려준다 — 축이 손 따라 움직이면 조작 감각이 깨진다.
|
||||
*/
|
||||
holdElevationRange: (
|
||||
next: { min: number; max: number } | null,
|
||||
) => { min: number; max: number } | undefined;
|
||||
/** 그래프 x → chainage 역변환이 필요한 도구 판정용 — 클릭 지점의 누가거리. */
|
||||
toolActive: () => boolean;
|
||||
/** [쉬프트]로 고른 직선 구간 — 그래프에 빨갛게 강조한다(2026-09-03). */
|
||||
@@ -222,13 +233,26 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
...graphData,
|
||||
stations: [...regular, ...injected].sort((a, b) => a.chainage_m - b.chainage_m),
|
||||
};
|
||||
// 세로 자동 맞춤 — 지금 화면에 보이는 누가거리 구간만 보고 Y 창을 잡는다(2026-09-04
|
||||
// 사용자 확정). 가로 스크롤 위치(`scrollLeft`)와 본문 폭이 곧 보이는 구간이다.
|
||||
const toChainage = chainageInverter(longitudinal, width, originOffset);
|
||||
const maxChainageM = maxChainageOf(longitudinal);
|
||||
const viewFromM = Math.max(0, toChainage(scrollLeft));
|
||||
const viewToM = Math.min(maxChainageM, toChainage(scrollLeft + body.clientWidth));
|
||||
const elevationRange = ctx.holdElevationRange(
|
||||
windowElevationRange(
|
||||
[graphLongitudinal.samples, ...designProfiles.map((profile) => profile.samples)],
|
||||
viewFromM,
|
||||
viewToM,
|
||||
) ?? null,
|
||||
);
|
||||
let yAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
|
||||
chartWrap.append(
|
||||
createLongitudinalProfile(
|
||||
graphLongitudinal,
|
||||
selectedStationId,
|
||||
// 세로 배율 = 표시 표고창 높이의 역수(1 = 표고 전범위).
|
||||
zoom.y,
|
||||
// 세로 배율은 1 고정 — 확대·축소 몫은 아래 `elevationRange`(자동 맞춤)가 맡는다.
|
||||
1,
|
||||
undefined,
|
||||
ctx.selectStation,
|
||||
stationInterval,
|
||||
@@ -252,8 +276,10 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
// 올린다. 19px는 라벨-버튼 사이가 너무 벌어져 70% 수준(15px)으로 줄였다
|
||||
// (2026-08-04 사용자 지시). B06은 편집 버튼이 없어 0 유지.
|
||||
15,
|
||||
// 표시 표고창의 중심 이동(창 높이 대비 비율) — ▲▼ 버튼이 옮긴다.
|
||||
zoom.offsetRatio,
|
||||
// 창 중심 이동은 쓰지 않는다 — 보이는 구간에 맞춘 Y 창이 이미 가운데다.
|
||||
0,
|
||||
// 보이는 구간의 지반·계획선 범위(위아래 10% 여유는 렌더러가 붙인다).
|
||||
elevationRange,
|
||||
),
|
||||
);
|
||||
// 구조물 우클릭 메뉴 — 측점선 위면 삭제, 빈 자리면 배관 추가. 이동도 여기(측점선 끌기)서 한다.
|
||||
@@ -373,6 +399,9 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
// 범례·기준 버튼 오버레이(top 34px)가 곡선 위에 떠서 그만큼 상단 여유를 준다
|
||||
// (2026-08-05 사용자 보고: 버튼과 커브 겹침).
|
||||
padTop: 40,
|
||||
// 유토곡선 Y 도 종단과 같은 창을 본다 — 전 구간 최대 토량으로 고정하면 확대해도
|
||||
// 곡선이 납작하게 눌린다(2026-09-04 사용자 지시).
|
||||
viewRange: { fromM: viewFromM, toM: viewToM },
|
||||
},
|
||||
stationInterval: stationIntervalM ?? 1,
|
||||
widthPx: width,
|
||||
|
||||
@@ -1,38 +1,30 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Zoom.ts
|
||||
* 종단면도 줌·Y레인지 조작구 (2026-09-04 사용자 지시).
|
||||
* 종단면도 줌 조작구 — 버튼 셋(줌인·줌아웃·초기화), 2026-09-04 사용자 확정.
|
||||
*
|
||||
* 공사 범위가 넓고 고저차가 크면 종단 그래프가 눌려 읽히지 않는다. 조작은 두 갈래다.
|
||||
* 공사 범위가 넓으면 종단 그래프가 눌려 읽히지 않는다. 사람이 맞출 것은 **가로 하나**다.
|
||||
*
|
||||
* X (가로) — **폭 배수**다. SVG transform 으로 늘리면 그래프만 커지고 측점 테이블·
|
||||
* 계획고 편집 버튼층·구조물 알약 레인이 어긋난다(넷이 같은 `chainageMapper`
|
||||
* 를 쓴다). 캔버스 폭 자체를 키우고 가로 스크롤로 훑는다.
|
||||
* Y (세로) — **표시 표고창**이다(제안 A, 2026-09-04 사용자 확정). 창 높이 = 전범위 ÷ 배율,
|
||||
* 창 중심은 창 높이 대비 비율로 위·아래로 옮긴다. 세로 스크롤이 생기지 않아
|
||||
* X축·측점 라벨·편집 버튼이 항상 바닥에 남는다.
|
||||
* Y (세로) — **프로그램이 자동으로 맞춘다**. 보이는 구간의 지반·계획선 범위에 맞춰
|
||||
* 잡으므로(`windowElevationRange`) 세로 배율·창 이동 버튼이 필요 없어졌다.
|
||||
* 옛 `⇕+`·`⇕−`·`▲`·`▼` 네 버튼은 그래서 없앴다.
|
||||
*
|
||||
* **배율 1 = 기본값이자 축소 한계**(사용자 확정) — 폭맞춤보다 더 줄이면 측점이 겹쳐
|
||||
* 읽을 수 없다. 한계에 닿은 버튼은 흐리게 죽인다.
|
||||
*
|
||||
* 배율은 페이지가 들고 있다 — 편집·재계산으로 다시 그려도 유지된다(B06 `cardZoomStates` 규칙).
|
||||
* 버튼 양식은 횡단도 줌 버튼세트(`b06-cross-card__zoom-btn`)와 같고, 설명은 툴팁이다.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 한 번 누를 때 배율 배수. 횡단도 줌(1/0.85)보다 성글게 — 폭 배수라 한 칸이 크게 느껴진다. */
|
||||
const ZOOM_STEP = 1.25;
|
||||
/** 가로 폭 배수 상한 — 이 이상은 캔버스가 수만 px이 되어 브라우저가 버겁다. */
|
||||
const MAX_X = 8;
|
||||
/** 세로 배율 상한. 전범위의 1/20 까지 좁혀 본다. */
|
||||
const MAX_Y = 20;
|
||||
/** 창 중심 이동 한 번의 몫 — 창 높이의 10%. */
|
||||
const OFFSET_STEP = 0.1;
|
||||
/** 창 중심 이동 한계 — 전범위 밖으로 완전히 벗어나지 않게 창 높이의 ±2배까지. */
|
||||
const MAX_OFFSET = 2;
|
||||
|
||||
export interface ProfileZoomState {
|
||||
/** 가로 폭 배수(1 = 현행 폭맞춤). */
|
||||
/** 가로 폭 배수(1 = 현행 폭맞춤 = 기본값·축소 한계). */
|
||||
x: number;
|
||||
/** 세로 배율(1 = 표고 전범위). */
|
||||
y: number;
|
||||
/** 창 중심 이동 — 창 높이 대비 비율(+ 가 위쪽). */
|
||||
offsetRatio: number;
|
||||
}
|
||||
|
||||
export interface ProfileZoom {
|
||||
@@ -45,12 +37,12 @@ const clamp = (value: number, min: number, max: number): number =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
|
||||
export function createProfileZoom(onChange: () => void): ProfileZoom {
|
||||
const state: ProfileZoomState = { x: 1, y: 1, offsetRatio: 0 };
|
||||
const state: ProfileZoomState = { x: 1 };
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "b05-profile__zoom";
|
||||
|
||||
function add(label: string, title: string, action: () => void): void {
|
||||
function add(label: string, title: string, action: () => void): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b05-profile__zoom-btn";
|
||||
@@ -60,34 +52,29 @@ export function createProfileZoom(onChange: () => void): ProfileZoom {
|
||||
// 카드·측점 선택으로 번지면 그래프를 다시 그리며 방금 맞춘 배율이 날아간다.
|
||||
event.stopPropagation();
|
||||
action();
|
||||
syncDisabled();
|
||||
onChange();
|
||||
});
|
||||
bar.append(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
add("+", "가로 확대 — 측점 간격을 넓혀 폅니다 (가로 스크롤로 훑음)", () => {
|
||||
const zoomIn = add("+", "가로 확대 — 측점 간격을 넓혀 폅니다 (세로는 자동으로 맞춥니다)", () => {
|
||||
state.x = clamp(state.x * ZOOM_STEP, 1, MAX_X);
|
||||
});
|
||||
add("−", "가로 축소", () => {
|
||||
const zoomOut = add("−", "가로 축소 — 기본 폭(화면 맞춤)까지만 줄어듭니다", () => {
|
||||
state.x = clamp(state.x / ZOOM_STEP, 1, MAX_X);
|
||||
});
|
||||
add("⇕+", "세로 확대 — 표시 표고 폭을 좁혀 고저차를 크게 봅니다", () => {
|
||||
state.y = clamp(state.y * ZOOM_STEP, 1, MAX_Y);
|
||||
});
|
||||
add("⇕−", "세로 축소", () => {
|
||||
state.y = clamp(state.y / ZOOM_STEP, 1, MAX_Y);
|
||||
});
|
||||
add("▲", "표시 표고창을 위로 (창 높이의 10%)", () => {
|
||||
state.offsetRatio = clamp(state.offsetRatio + OFFSET_STEP, -MAX_OFFSET, MAX_OFFSET);
|
||||
});
|
||||
add("▼", "표시 표고창을 아래로 (창 높이의 10%)", () => {
|
||||
state.offsetRatio = clamp(state.offsetRatio - OFFSET_STEP, -MAX_OFFSET, MAX_OFFSET);
|
||||
});
|
||||
add("⤢", "가로·세로 배율과 표시 표고창을 처음 상태로", () => {
|
||||
add("⤢", "기본 상태로 — 가로 폭맞춤, 세로 자동", () => {
|
||||
state.x = 1;
|
||||
state.y = 1;
|
||||
state.offsetRatio = 0;
|
||||
});
|
||||
|
||||
/** 한계에 닿은 버튼은 눌러도 변화가 없다 — 흐리게 죽여 그 사실을 보인다. */
|
||||
function syncDisabled(): void {
|
||||
zoomOut.disabled = state.x <= 1 + 1e-9;
|
||||
zoomIn.disabled = state.x >= MAX_X - 1e-9;
|
||||
}
|
||||
syncDisabled();
|
||||
|
||||
return { bar, state: () => ({ ...state }) };
|
||||
}
|
||||
|
||||
@@ -509,7 +509,13 @@
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.b05-profile__zoom-btn:hover {
|
||||
.b05-profile__zoom-btn:hover:not(:disabled) {
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 배율 한계(축소는 폭맞춤, 확대는 8배)에 닿은 버튼 — 눌러도 변화가 없으니 흐리게 죽인다. */
|
||||
.b05-profile__zoom-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user