From b51c8875499e1f5cac0afec0865dbfda7df82166 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 18:14:59 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(B05/B06):=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=EA=B7=B8=EB=9E=98=ED=94=84=20=EC=A4=8C=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EB=8B=A8=EC=88=9C=ED=99=94=20+=20=EC=84=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 줌 버튼을 셋(줌인·줌아웃·초기화)으로 줄임. 세로 배율·창 이동 버튼 제거 - 배율 1 = 기본값이자 축소 한계 — 한계에 닿은 버튼은 흐리게 죽임 - 세로는 보이는 누가거리 구간의 지반·계획선 범위로 자동(공통 함수 windowElevationRange, B05·B06 종단이 함께 씀). 스크롤이 멈춘 뒤 0.16초에 갱신 - 계획고 편집 버튼을 누르고 있는 동안 Y 축 고정, 손을 떼면 다시 맞춤 - 유토곡선 Y 도 같은 창 기준(전 구간 ±200㎥ 고정 해제) - B06 종단이 쓰던 공통 Y 스케일(calculateYScale) 제거 — 횡단 카드는 원래 안 쓰던 값 Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 44 +++++++++++++- B05_Profile/B05_Profile_UI_Profile_Render.ts | 41 +++++++++++-- B05_Profile/B05_Profile_UI_Profile_Zoom.ts | 59 ++++++++----------- B05_Profile/B05_Profile_UI_Style_Table.css | 8 ++- B06_Section/B06_Section_UI_Longitudinal.ts | 12 +++- B06_Section/B06_Section_UI_Section_Common.ts | 41 +++++++++++++ B06_Section/B06_Section_UI_Section_View.ts | 50 ++++++++++++++-- .../B06_Section_UI_Section_View_MassHaul.ts | 3 + common_util/common_util_mass_haul_view.ts | 45 +++++++++++++- 9 files changed, 249 insertions(+), 54 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 23da8778..a2580a54 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -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( diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 383a4cfe..6d4662c3 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -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, diff --git a/B05_Profile/B05_Profile_UI_Profile_Zoom.ts b/B05_Profile/B05_Profile_UI_Profile_Zoom.ts index b6d13d86..fb29af27 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Zoom.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Zoom.ts @@ -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 }) }; } diff --git a/B05_Profile/B05_Profile_UI_Style_Table.css b/B05_Profile/B05_Profile_UI_Style_Table.css index 85044eca..5b5fec88 100644 --- a/B05_Profile/B05_Profile_UI_Style_Table.css +++ b/B05_Profile/B05_Profile_UI_Style_Table.css @@ -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; +} diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index 6facaf19..d942ac73 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -174,6 +174,12 @@ export function createLongitudinalProfile( * 표고(m)가 아니라 비율이라 호출부가 노선 표고 범위를 몰라도 된다(2026-09-04). */ elevationOffsetRatio = 0, + /** + * **보이는 구간의 표고 범위**(자동 세로 맞춤, 2026-09-04 사용자 확정). 넘기면 전 구간 + * 최저~최고 대신 이 범위로 Y 창을 잡는다 — 가로로 확대했을 때 그 구간의 고저차가 + * 화면 높이를 채운다. 공통 Y 스케일(`yScaleOptions`)이 있으면 그쪽이 우선이다. + */ + elevationRange?: { min: number; max: number }, ): HTMLElement { const samples = data.samples.filter(validElevation); if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal")); @@ -198,8 +204,10 @@ export function createLongitudinalProfile( const elevations = samples .map((sample) => sample.elevation_m) .concat(designProfiles.flatMap((profile) => profile.samples.map((s) => s.elevation_m))); - const rawMin = yScaleOptions?.globalMinElevation ?? Math.min(...elevations); - const rawMax = yScaleOptions?.globalMaxElevation ?? Math.max(...elevations); + const rawMin = + yScaleOptions?.globalMinElevation ?? elevationRange?.min ?? Math.min(...elevations); + const rawMax = + yScaleOptions?.globalMaxElevation ?? elevationRange?.max ?? Math.max(...elevations); const elevationMid = (rawMin + rawMax) / 2; const exaggeration = Math.max(verticalExaggeration, 0.1); // 데이터 영역은 축 프레임(LONG_PAD)보다 originOffsetPx만큼 더 좁게 잡아, diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index ffa54a7a..f745e7b6 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -223,6 +223,47 @@ export function calculateYScale( }; } +/** + * **보이는 구간의 표고 최저·최고**(종단 그래프 세로 자동 맞춤, 2026-09-04 사용자 확정). + * + * 가로로 확대하면 화면에는 노선의 일부만 남는데 Y 축은 전 구간 범위로 잡혀 있어 곡선이 + * 납작하게 눌린다. 보이는 누가거리 구간만 훑어 그 구간의 범위를 돌려준다 — B05 종단과 + * B06 종단이 같은 함수를 쓴다. + * + * 창 밖 **이웃 한 점**까지 함께 본다. 창 경계를 걸친 선분이 창 안에서 위로 솟는데 그 + * 바깥 끝점을 빼면 선이 축 위로 삐져나온다. + */ +export function windowElevationRange( + series: ReadonlyArray>, + fromM: number, + toM: number, +): { min: number; max: number } | undefined { + let min = Infinity; + let max = -Infinity; + for (const list of series) { + let first = -1; + let last = -1; + for (let index = 0; index < list.length; index += 1) { + const chainage = list[index].chainage_m ?? 0; + if (chainage < fromM || chainage > toM) continue; + if (first < 0) first = index; + last = index; + } + if (first < 0) continue; + for ( + let index = Math.max(0, first - 1); + index <= Math.min(list.length - 1, last + 1); + index += 1 + ) { + const elevation = list[index].elevation_m; + if (typeof elevation !== "number" || !Number.isFinite(elevation)) continue; + if (elevation < min) min = elevation; + if (elevation > max) max = elevation; + } + } + return min <= max ? { min, max } : undefined; +} + export function emptyView(message: string): HTMLElement { const empty = document.createElement("div"); empty.className = "b06-section__empty"; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 2bf11752..8b445399 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -59,7 +59,6 @@ import { unwrapChart, } from "./B06_Section_UI_Section_View_Panel"; import { - calculateYScale, CROSS_GRID_GAP, CROSS_GRID_MIN_WIDTH, CROSS_WIDTH, @@ -69,7 +68,9 @@ import { emptyView, inferStationInterval, L, - type YScaleOptions, + LONG_PAD, + longitudinalMaxChainage, + windowElevationRange, } from "./B06_Section_UI_Section_Common"; export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl }; @@ -142,7 +143,6 @@ export function createSectionView( let lastChartAvailable = 0; let chartFitScheduled = false; // 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신) - let cachedYScale: YScaleOptions | undefined; let cachedStationInterval = 1; let cachedCardWidth = CROSS_WIDTH; // 같은 행 카드는 같은 높이가 되도록 draw에서 측점별 행 높이를 계산해 둔다(단건 갱신도 이 값 재사용). @@ -473,8 +473,24 @@ export function createSectionView( const heights = chartHeights(lastChartAvailable); const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval); const chartWidth = Math.max(renderWidth, minWidth); - // 종단도 높이가 줄면 Y스케일도 그 높이로 다시 잡아야 표고가 잘리지 않는다. - cachedYScale = calculateYScale(detail, heights.long); + // 종단 그래프의 세로는 **보이는 구간에 자동으로 맞춘다**(2026-09-04 사용자 확정, + // B05 와 같은 규칙·같은 함수). 가로 스크롤 위치와 컨테이너 폭이 곧 보이는 구간이다. + const maxChainageM = longitudinalMaxChainage(detail.longitudinal); + const plotWidth = Math.max(1, chartWidth - LONG_PAD.left - LONG_PAD.right); + const toChainage = (px: number): number => ((px - LONG_PAD.left) / plotWidth) * maxChainageM; + const viewFromM = Math.max(0, toChainage(keepScrollLeft)); + const viewToM = Math.min( + maxChainageM, + toChainage(keepScrollLeft + (chartWrap.clientWidth || chartWidth)), + ); + const longElevationRange = windowElevationRange( + [ + detail.longitudinal.samples, + ...(detail.longitudinal.design_profiles ?? []).map((profile) => profile.samples), + ], + viewFromM, + viewToM, + ); // Y축 눈금을 렌더러에서 받아 가로 스크롤 고정 오버레이로 얹는다(2026-08-04 사용자 // 지시 — 테이블 행 이름표처럼 스크롤해도 계속 보이게, B05와 같은 방식). @@ -485,7 +501,8 @@ export function createSectionView( detail.longitudinal, selectedStationId, currentExaggeration, - cachedYScale, + // 공통 Y 스케일(횡단 카드 몫)을 여기 넘기면 종단이 전 구간 축에 묶여 눌린다. + undefined, (stationId) => selectStation(stationId, true), cachedStationInterval, chartWidth, @@ -496,6 +513,11 @@ export function createSectionView( (axis) => { longAxis = axis; }, + undefined, + undefined, + 0, + 0, + longElevationRange, ), ), ]; @@ -517,6 +539,8 @@ export function createSectionView( selectStation: (stationId) => selectStation(stationId, true), toggleSeries, redraw: drawPanel, + // 유토곡선도 종단과 같은 창을 본다(2026-09-04). + viewRange: { fromM: viewFromM, toM: viewToM }, }); if (massHaul.chart) nodes.push(massHaul.chart); chartWrap.replaceChildren(...nodes); @@ -553,6 +577,20 @@ export function createSectionView( } } + /** 가로 스크롤이 이만큼 멈춰 있으면 보이는 구간이 정해진 것으로 보고 세로를 다시 맞춘다. */ + const SCROLL_SETTLE_MS = 160; + let settledScrollLeft = 0; + let scrollSettleTimer = 0; + // 스크롤·팬 하는 내내 축이 출렁이면 어지럽다 — 멈춘 뒤 한 번만 다시 그린다(2026-09-04). + chartWrap.addEventListener("scroll", () => { + window.clearTimeout(scrollSettleTimer); + scrollSettleTimer = window.setTimeout(() => { + if (Math.abs(chartWrap.scrollLeft - settledScrollLeft) < 1) return; + settledScrollLeft = chartWrap.scrollLeft; + drawPanel(); + }, SCROLL_SETTLE_MS); + }); + const draw = (): void => { if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return; const detail = currentDetail; diff --git a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts index cdb9dd5e..0a728daf 100644 --- a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts +++ b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts @@ -56,6 +56,8 @@ export interface MassHaulPanelInput { toggleSeries: (key: string) => void; /** 범례에서 도형 위치를 초기화한 뒤 패널을 다시 그린다. */ redraw: () => void; + /** 화면에 보이는 누가거리 구간(m) — Y 를 이 구간의 누계 토량으로 잡는다(2026-09-04). */ + viewRange?: { fromM: number; toM: number }; } export interface MassHaulPanelResult { @@ -104,6 +106,7 @@ export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResu maxChainageM: longitudinalMaxChainage(detail.longitudinal), padLeft: LONG_PAD.left, padRight: LONG_PAD.right, + viewRange: input.viewRange, }, input.selectedStationId, input.stationInterval, diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index f981e629..d06e516f 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -43,6 +43,12 @@ export interface MassHaulAxis { axisX?: number; /** 그래프 위 여백(px). 생략하면 기본(10). B05는 범례 오버레이만큼 크게 준다. */ padTop?: number; + /** + * **화면에 보이는 누가거리 구간**(m). 넘기면 Y 범위를 이 구간의 누계 토량으로 잡는다 + * (2026-09-04 사용자 지시 — 종단 그래프의 세로 자동 맞춤과 같은 창). 생략하면 예전처럼 + * 전 구간 기준 ±200㎥ 고정이다. + */ + viewRange?: { fromM: number; toM: number }; } /** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */ @@ -128,9 +134,44 @@ const VOLUME_RANGE_BASE_M3 = 200; * Y축 상·하한을 잡는다. 기본 −200~+200㎥ 고정(0선 항상 포함), 곡선이 넘치는 쪽만 * 데이터에 5% 여유를 더해 확장한다. B05·B06이 같은 함수를 쓰므로 두 화면이 함께 고정된다. */ -function volumeRange(series: MassHaulSeries[]): { min: number; max: number } { +function volumeRange( + series: MassHaulSeries[], + viewRange?: { fromM: number; toM: number }, +): { min: number; max: number } { let rawMin = 0; let rawMax = 0; + if (viewRange) { + // 보이는 구간만 훑는다. 창 경계를 걸친 선분이 안에서 솟구치므로 바깥 이웃 한 점도 본다. + let found = false; + for (const entry of series) { + const points = entry.result.points; + let first = -1; + let last = -1; + for (let index = 0; index < points.length; index += 1) { + const chainage = points[index].chainage_m; + if (chainage < viewRange.fromM || chainage > viewRange.toM) continue; + if (first < 0) first = index; + last = index; + } + if (first < 0) continue; + for ( + let index = Math.max(0, first - 1); + index <= Math.min(points.length - 1, last + 1); + index += 1 + ) { + const volume = points[index].cumulative_volume_m3; + if (!Number.isFinite(volume)) continue; + rawMin = found ? Math.min(rawMin, volume) : volume; + rawMax = found ? Math.max(rawMax, volume) : volume; + found = true; + } + } + if (found) { + // 창 안이 거의 평평하면(구간 토량 변화가 없으면) 최소 폭을 줘 선이 축에 붙지 않게 한다. + const padding = Math.max((rawMax - rawMin) * 0.05, 1); + return { min: rawMin - padding, max: rawMax + padding }; + } + } for (const entry of series) { rawMin = Math.min(rawMin, entry.result.min_cumulative_m3); rawMax = Math.max(rawMax, entry.result.max_cumulative_m3); @@ -323,7 +364,7 @@ export function createMassHaulChart( // 기준 버튼이 라디오가 되면서(택1 표시) Y 범위도 **표시 중인 곡선**으로 잡는다 — // 숨은 기준까지 합쳐 잡으면 선택한 그래프가 눌려 보인다. 아무것도 안 켰으면 전체로 폴백. const rangeSource = series.filter((entry) => visibleKeys.has(entry.key)); - const { min, max } = volumeRange(rangeSource.length ? rangeSource : series); + const { min, max } = volumeRange(rangeSource.length ? rangeSource : series, axis.viewRange); const span = Math.max(max - min, 1e-6); const y = (volume: number) => padTop + ((max - volume) / span) * plotHeight; From 6ff86cdd2059b760fd179614993a7d2390c40f7a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 18:26:26 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(B05):=203D=20=EB=B7=B0=EC=96=B4=20?= =?UTF-8?q?=EC=9B=90=EA=B7=BC=20=EC=B9=B4=EB=A9=94=EB=9D=BC=20=EB=B3=B5?= =?UTF-8?q?=EA=B7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-04) — 「이전 방식이 좋았음」. 2026-08-25 `f6296fa7` 에서 직교로 바꿨던 것을 시야각 45° 원근으로 되돌림. - 직교 전용 모듈 `B05_Profile_UI_Viewer_Camera.ts` 삭제, 뷰어 4자리 복원 (카메라 생성 · 리사이즈 종횡비 · 화면맞춤 · 투영행렬). - 근평면 상한 0.4m 신설 — 휠 확대가 커서 아래 지점 0.5m 앞에서 멈추는데 근평면을 맞춤거리의 1/1000 로만 두면 긴 노선에서 그 0.5m 를 넘어 지형이 잘림 (용화_LAS 맞춤거리 1,208m → 상한 없으면 근평면 1.21m). 자체검증(공용 브라우저 5173, 용화_LAS) — 네 방향 모두 원근 45°, 확대 한계 0.516m > 근평면 0.4m 로 잘림 없음, 구조물 클릭 선택·해제 그대로. Co-Authored-By: Claude Opus 5 --- B05_Profile/B05_Profile_UI_Viewer.ts | 18 +++++---- B05_Profile/B05_Profile_UI_Viewer_Camera.ts | 42 --------------------- 2 files changed, 11 insertions(+), 49 deletions(-) delete mode 100644 B05_Profile/B05_Profile_UI_Viewer_Camera.ts diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 04bcd643..8d067309 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -14,7 +14,6 @@ import { type RouteMarkers, type SectionStationMarker, } from "./B05_Profile_UI_Markers"; -import { createOrthoCameraRig } from "./B05_Profile_UI_Viewer_Camera"; import { bindMarkerPointerControls } from "./B05_Profile_UI_Viewer_Marker_Input"; import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import { @@ -165,8 +164,10 @@ export function createRouteViewer(): RouteViewer { }); systemDarkTheme.addEventListener("change", updateSceneBackground); updateSceneBackground(); - const cameraRig = createOrthoCameraRig(); - const camera = cameraRig.camera; + // 원근 카메라(시야각 45°) — 2026-09-04 사용자 지시로 직교에서 되돌렸다. 직교가 + // 필요했던 탑뷰 구조물 투영 윤곽선은 2026-09-02에 숨김 처리되어 화면에 없다. + const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100000); + camera.position.set(100, 120, 100); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); const controls = new OrbitControls(camera, canvas); @@ -275,7 +276,8 @@ export function createRouteViewer(): RouteViewer { const width = Math.max(1, root.clientWidth); const height = Math.max(1, root.clientHeight); renderer.setSize(width, height, false); - cameraRig.setAspect(width / height); + camera.aspect = width / height; + camera.updateProjectionMatrix(); } const resizeObserver = new ResizeObserver(resize); resizeObserver.observe(root); @@ -291,10 +293,12 @@ export function createRouteViewer(): RouteViewer { } as const; const [x, y, z] = positions[view]; camera.position.set(target.x + x, target.y + y, target.z + z); - camera.near = Math.max(0.1, distance / 1000); + // 근평면 상한 0.4m — 휠 확대는 커서 아래 지점 0.5m 앞에서 멈춘다(커서 피벗 유틸). + // 거리에만 비례시키면 긴 노선(맞춤 거리 1km 이상)에서 근평면이 그 0.5m를 넘어 + // 최대 확대 시 지형이 잘린다(2026-09-04 원근 복귀 실측: 400m 노선 여유 13mm). + camera.near = Math.max(0.1, Math.min(0.4, distance / 1000)); camera.far = distance * 10; - // 원근 45°(반각 tan ≈ 0.414)와 비슷한 화면 배율 — 뷰 전환 시 크기감이 유지된다. - cameraRig.setHalfHeight(distance * 0.42); + camera.updateProjectionMatrix(); controls.update(); } diff --git a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts deleted file mode 100644 index 6e36d63b..00000000 --- a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Viewer_Camera.ts - * B05 뷰어의 **직교(원근 없음) 카메라**(2026-08-25 사용자 확정) — 탑뷰에서 구조물· - * 절단 경계가 원근으로 일그러지지 않는다. 화면 배율은 camera.zoom이 지고(커서 피벗 - * 유틸이 조작), 절두체 반높이는 fit()이 정한다. Viewer 700줄 제한으로 분리. - * ========================================================================== */ - -import * as THREE from "three"; - -export interface OrthoCameraRig { - camera: THREE.OrthographicCamera; - /** 뷰포트 종횡비 반영(리사이즈 시). */ - setAspect(aspect: number): void; - /** 절두체 반높이(월드 m) 지정 — fit()이 화면 배율을 잡을 때 쓴다. zoom은 1로 되돌린다. */ - setHalfHeight(halfHeight: number): void; -} - -export function createOrthoCameraRig(): OrthoCameraRig { - const camera = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 100000); - camera.position.set(100, 120, 100); - let halfHeight = 100; - let aspect = 1; - const apply = (): void => { - camera.left = -halfHeight * aspect; - camera.right = halfHeight * aspect; - camera.top = halfHeight; - camera.bottom = -halfHeight; - camera.updateProjectionMatrix(); - }; - return { - camera, - setAspect(value: number): void { - aspect = value; - apply(); - }, - setHalfHeight(value: number): void { - halfHeight = value; - camera.zoom = 1; - apply(); - }, - }; -} From 84042fe010ed4cbf602b96255a52991d45ea4998 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 18:34:05 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(B05):=203D=20=EC=A0=88=ED=86=A0=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=20=EC=B8=A1=EC=A0=90=20=ED=91=9C=EA=B3=A0=20?= =?UTF-8?q?=C2=B7=20=EC=A0=84=20=EC=B8=A1=EC=A0=90=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=20=EC=86=8E=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지적(2026-09-04) — 「절토가 된 경우 측점 표시가 안 따라감」, 「측점 라벨이 5측점 기준으로만 붙음, 전체 라벨이 있으면 좋겠음」. - 측점 막대 표고에서 `max(지반, 계획고)` 제거. 코리도가 켜져 있으면 계획고를 그대로 씀 — 절토는 계획고가 지반보다 아래라 max 로는 막대만 원지반에 떠 있었음 (실측 0측점 3.2m 공중). - 라벨을 규칙 측점 전부에 만들고(`STATION_LABEL_STEP` 제거), 카메라 거리로 솎음 (`LABEL_LOD` 150m 미만 전부 · 400m 미만 2칸 · 그 밖 5칸). 구조물·BP·EP 는 항상. - 뷰어 렌더 루프에서 카메라~시점 거리로 단계를 갱신(단계 불변이면 즉시 반환). 자체검증(공용 브라우저 5173, 용화_LAS) — 절토 3측점·성토 10측점 모두 막대와 코리도 표면 높이차 0.80m(띄움값). 라벨 66개 중 화면맞춤 23 · 중간 39 · 근접 66. Co-Authored-By: Claude Opus 5 --- B05_Profile/B05_Profile_UI_Markers.ts | 57 +++++++++++++++++++++------ B05_Profile/B05_Profile_UI_Page.ts | 8 ++-- B05_Profile/B05_Profile_UI_Viewer.ts | 2 + 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Markers.ts b/B05_Profile/B05_Profile_UI_Markers.ts index 371a9842..7e304fa5 100644 --- a/B05_Profile/B05_Profile_UI_Markers.ts +++ b/B05_Profile/B05_Profile_UI_Markers.ts @@ -43,8 +43,16 @@ export interface SectionStationMarker { structure?: string; } -/** 규칙 측점 라벨을 몇 칸마다 달지. 전부 달면 글자가 겹쳐 도면을 못 읽는다. */ -const STATION_LABEL_STEP = 5; +/** + * 규칙 측점 라벨 솎기 — 라벨은 **전 측점에 만들어 두고** 카메라 거리로 골라 보인다 + * (2026-09-04 사용자 지시 「전체 라벨이 있으면 좋겠음」). 멀면 글자가 겹치므로 + * 5칸 → 2칸 → 전부로 단계를 올린다. 경계는 카메라~시점거리(m). + */ +const LABEL_LOD: ReadonlyArray<{ within: number; step: number }> = [ + { within: 150, step: 1 }, + { within: 400, step: 2 }, + { within: Infinity, step: 5 }, +]; // 측점 바 양 끝 원형 램프 색: 상단(등고 높은 쪽) 예상측=주황, 반대측=회색. const UPHILL_LAMP_COLOR = 0xf97316; @@ -293,28 +301,42 @@ export function createRouteMarkers( * * BP·EP — 시·종점은 항상. 이름을 앞에 붙여 어느 끝인지 바로 읽히게 한다. * 구조물(비정규) — 측점번호 + 구조물 이름(배관 등). - * 5측점 배수 — 규칙 측점은 5칸마다만. 전부 달면 글자가 겹쳐 도면을 못 읽는다. + * 규칙 측점 — 전부 만든다. 몇 개를 보일지는 카메라 거리가 정한다(`LABEL_LOD`). * * 측점번호는 라벨 표기(`측점번호+잔여거리`)에서 되짚는다 — 측점간격은 렌더러가 모른다. - * 잔여거리가 남은 측점(예: `4+12.3`)은 규칙 격자가 아니므로 배수 판정에서 뺀다. + * 잔여거리가 남은 측점(예: `4+12.3`)은 규칙 격자가 아니므로 솎기 판정에서 뺀다 + * (`number: null` = 거리와 무관하게 항상 보임). */ - function stationLabelText(station: SectionStationMarker, intervalM: number): string | null { + function stationLabelText( + station: SectionStationMarker, + intervalM: number, + ): { text: string; number: number | null } | null { const chainage = station.chainage_m; if (!Number.isFinite(chainage)) return null; // 표기는 종단 그래프·도면 테이블과 **같은 규칙**(`측점번호+잔여거리`)을 쓴다. // 서버가 내려주는 `label`(`STA.0+000.000`)을 그대로 쓰면 화면마다 표기가 갈린다. const text = stationLabel(chainage as number, intervalM); - if (station.kind === "bp") return `BP ${text}`; - if (station.kind === "ep") return `EP ${text}`; + if (station.kind === "bp") return { text: `BP ${text}`, number: null }; + if (station.kind === "ep") return { text: `EP ${text}`, number: null }; if (station.kind === "irregular") { const structure = station.structure?.trim(); - return structure ? `${text} ${structure}` : text; + return { text: structure ? `${text} ${structure}` : text, number: null }; } const safeInterval = intervalM > 0 ? intervalM : 1; const stationNumber = Math.round((chainage as number) / safeInterval); const remainder = (chainage as number) - stationNumber * safeInterval; if (Math.abs(remainder) > 0.05) return null; - return stationNumber % STATION_LABEL_STEP === 0 ? text : null; + return { text, number: stationNumber }; + } + + /** 지금 솎기 단계(몇 칸마다 보일지). 카메라 거리로 바뀐다. */ + let labelStep = LABEL_LOD[LABEL_LOD.length - 1].step; + + function applyLabelStep(): void { + stationLabelGroup.children.forEach((child) => { + const number = (child.userData as { stationNumber?: number | null }).stationNumber; + child.visible = typeof number !== "number" || number % labelStep === 0; + }); } /** @@ -405,9 +427,11 @@ export function createRouteMarkers( // 측점 바 양 끝 원형 램프: 상단(등고 높은 쪽) 예상측 컬러, 반대측 회색. // 클릭하면 그 측을 상단측(=측구 방향)으로 지정한다(onUphillPick). - const labelText = stationLabelText(station, stationIntervalM); - if (labelText) { - stationLabelGroup.add(stationLabelSprite(labelText, modelToScene(center, bounds))); + const label = stationLabelText(station, stationIntervalM); + if (label) { + const sprite = stationLabelSprite(label.text, modelToScene(center, bounds)); + sprite.userData.stationNumber = label.number; + stationLabelGroup.add(sprite); } (["left", "right"] as const).forEach((side, endIndex) => { @@ -435,6 +459,8 @@ export function createRouteMarkers( stationGroup.add(lampHit); }); }); + // 새로 만든 라벨에도 지금 솎기 단계를 그대로 먹인다. + applyLabelStep(); // 재렌더로 좌표가 갱신됐으니 선택 핀도 그 자리로 다시 놓는다. syncSelectionPin(); } @@ -537,6 +563,13 @@ export function createRouteMarkers( setStationLabelsVisible(visible: boolean) { stationLabelGroup.visible = visible; }, + /** 카메라~시점 거리(m)로 규칙 측점 라벨을 솎는다. 구조물·BP·EP 는 늘 보인다. */ + updateLabelDetail(distanceM: number) { + const step = (LABEL_LOD.find((lod) => distanceM < lod.within) ?? LABEL_LOD[0]).step; + if (step === labelStep) return; + labelStep = step; + applyLabelStep(); + }, onChange(listener: (next: RouteDesignPoints) => void) { changeListener = listener; }, diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index ba1a5267..10bee531 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -423,10 +423,10 @@ export async function renderB05Route(root: HTMLElement): Promise { const design = designAt(station.chainage_m); return { ...station, - center_z: - design !== null && station.center_z !== null - ? Math.max(station.center_z, design) - : station.center_z, + // 절토 구간에서는 계획고가 지반보다 **아래**다(2026-09-04 사용자 지적). + // max 로 잡으면 코리도가 절취해 내려간 노면을 두고 막대만 원지반에 떠 있다. + // 코리도가 켜져 있으면(designAt 이 값을 줌) 계획고를 그대로 쓴다. + center_z: design !== null && station.center_z !== null ? design : station.center_z, uphill_side: uphillOverrides.get(uphillKey(station.chainage_m)) ?? station.uphill_side ?? null, }; diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 8d067309..c09b1096 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -367,6 +367,8 @@ export function createRouteViewer(): RouteViewer { } else { compass.setVisible(false); } + // 측점 라벨 솎기 — 가까울수록 촘촘히 보인다(단계가 안 바뀌면 모듈 안에서 걸러낸다). + markers.updateLabelDetail(camera.position.distanceTo(controls.target)); renderer.render(scene, camera); } animate(); From 4be8c19e97f00df9263c7e201e61543e50ab3771 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 18:36:46 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(B06):=20=ED=91=9C=EC=A4=80=20=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=EB=A9=B4=20=EC=84=A4=EC=A0=95=EC=9D=84=20=E3=80=8C?= =?UTF-8?q?=ED=91=9C=EC=A4=80=ED=9A=A1=EB=8B=A8=EB=A9=B4=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EA=B0=92=E3=80=8D=20=ED=95=9C=20=EC=BB=A8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=84=88=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 토사/암/포장 3그룹이 같은 필드를 각각 갖던 화면을 공통 한 벌로 합침 (칸 31 → 15) - 공통 = 노폭·노견·측구·절토/성토 경사·횡단 경사, 구분선 뒤 암 = 절토 경사·L형 측구, 구분선 뒤 포장 = 횡단 경사 - 저장 구조는 3그룹 그대로 — 공통값은 고칠 때 세 그룹에 펼쳐 넣음 - 옛 프로젝트가 그룹마다 다른 공통값을 갖고 있으면 토사 값 기준으로 한 벌 통일 (사용자 확정 2026-09-04) Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Standard_Panel.ts | 201 ++++++++++++++----- B06_Section/B06_Section_UI_Style.css | 11 + ui_template/ui_template_locale_b2.ts | 4 + 3 files changed, 161 insertions(+), 55 deletions(-) diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index a74ccf3b..d2154fea 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -1,6 +1,12 @@ /* ============================================================================= * B06_Section_UI_Standard_Panel.ts - * 좌측 사이드 "표준 횡단면 설정" 패널 (토사 / 암 / 포장 3그룹). + * 좌측 사이드 "표준 횡단면 설정" 패널 — **「표준횡단면 상세값」 한 컨테이너**(2026-09-04 + * 사용자 지시). 토사·암·포장 3그룹이 같은 필드를 각각 갖던 화면을 공통 한 벌 + + * 구간별로 다른 값만 남겼다. 공통 = 노폭·노견·측구·절토/성토 경사·횡단 경사, + * 암 = 절토 경사·L형 측구, 포장 = 횡단 경사. + * + * **저장 구조는 그대로 3그룹**(`standard_cross_section`) — 화면만 합치고 저장할 때 + * 공통값을 세 그룹에 펼쳐 넣는다. 백엔드·기존 프로젝트가 그대로 동작한다. * * 각 그룹의 노폭·노견·측구 규격·경사값을 편집한다. 기본값은 백엔드 config * (STANDARD_CROSS_SECTION, context.standard_cross_section)에서 내려오고, 사용자가 @@ -26,12 +32,6 @@ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -const GROUP_ORDER: Array<[StandardCrossKey, keyof typeof ui_locales]> = [ - ["soil", "B06_Std_Group_Soil"], - ["rock", "B06_Std_Group_Rock"], - ["paved", "B06_Std_Group_Paved"], -]; - const SESSION_PREFIX = "b06:std-cross:"; /** config 기본값 사본 — 편집값(`SESSION_PREFIX`)과 수명은 같되 용도가 다르다. */ const DEFAULTS_PREFIX = "b06:std-cross-default:"; @@ -151,49 +151,98 @@ export interface StandardPanelController { applyStored: (stored: StandardCrossSection) => void; } +/** 값을 어느 그룹에 쓸 것인가. `common` 은 세 그룹에 함께 펼쳐 넣는다. */ +type FieldScope = "common" | "rock" | "paved"; + interface NumberFieldSpec { - label: string; + label: keyof typeof ui_locales; + scope: FieldScope; + /** 화면에 보일 값을 읽는다 — 공통은 **토사 값이 기준**(2026-09-04 사용자 확정). */ get: (group: StandardCrossGroup) => number; set: (group: StandardCrossGroup, value: number) => void; - /** 암 그룹의 L형 측구처럼 특정 그룹에만 존재하는 필드는 조건으로 거른다. */ - only?: StandardCrossKey; + /** + * 공통값이지만 이 그룹은 따로 값을 갖는다 — 공통을 펼칠 때 건너뛴다. + * (절토 경사는 암이, 횡단 경사는 포장이 자기 값을 쓴다) + */ + exclude?: StandardCrossKey; } -/** 그룹 하나에 노출할 편집 필드 정의. 순서 = 화면 표기 순서. */ +/** 한 컨테이너에 늘어놓을 편집 필드. 순서 = 화면 표기 순서. */ const FIELD_SPECS: NumberFieldSpec[] = [ { label: "B06_Std_Field_RoadWidth", + scope: "common", get: (g) => g.road_width_m, set: (g, v) => (g.road_width_m = v), }, { label: "B06_Std_Field_ShoulderLeft", + scope: "common", get: (g) => g.shoulder_left_m, set: (g, v) => (g.shoulder_left_m = v), }, { label: "B06_Std_Field_ShoulderRight", + scope: "common", get: (g) => g.shoulder_right_m, set: (g, v) => (g.shoulder_right_m = v), }, { label: "B06_Std_Field_DitchTop", + scope: "common", get: (g) => g.ditch.top_width_m, set: (g, v) => (g.ditch.top_width_m = v), }, { label: "B06_Std_Field_DitchBottom", + scope: "common", get: (g) => g.ditch.bottom_width_m, set: (g, v) => (g.ditch.bottom_width_m = v), }, { label: "B06_Std_Field_DitchDepth", + scope: "common", get: (g) => g.ditch.depth_m, set: (g, v) => (g.ditch.depth_m = v), }, + { + // 암은 아래에서 자기 절토 경사를 따로 가진다 — 공통은 토사·포장 몫이다. + label: "B06_Std_Field_CutSlope", + scope: "common", + exclude: "rock", + get: (g) => g.cut_slope_ratio, + set: (g, v) => (g.cut_slope_ratio = v), + }, + { + label: "B06_Std_Field_FillSlope", + scope: "common", + get: (g) => g.fill_slope_ratio, + set: (g, v) => (g.fill_slope_ratio = v), + }, + { + // 포장은 아래에서 자기 횡단 경사를 따로 가진다. + label: "B06_Std_Field_CrossSlopeMin", + scope: "common", + exclude: "paved", + get: (g) => g.cross_slope_pct.min, + set: (g, v) => (g.cross_slope_pct.min = v), + }, + { + label: "B06_Std_Field_CrossSlopeMax", + scope: "common", + exclude: "paved", + get: (g) => g.cross_slope_pct.max, + set: (g, v) => (g.cross_slope_pct.max = v), + }, + { + label: "B06_Std_Field_CutSlope", + scope: "rock", + get: (g) => g.cut_slope_ratio, + set: (g, v) => (g.cut_slope_ratio = v), + }, { label: "B06_Std_Field_LDitchWidth", - only: "rock", + scope: "rock", get: (g) => g.ditch_l_type?.width_m ?? 0, set: (g, v) => { g.ditch_l_type = { width_m: v, depth_m: g.ditch_l_type?.depth_m ?? 0 }; @@ -201,34 +250,49 @@ const FIELD_SPECS: NumberFieldSpec[] = [ }, { label: "B06_Std_Field_LDitchDepth", - only: "rock", + scope: "rock", get: (g) => g.ditch_l_type?.depth_m ?? 0, set: (g, v) => { g.ditch_l_type = { width_m: g.ditch_l_type?.width_m ?? 0, depth_m: v }; }, }, - { - label: "B06_Std_Field_CutSlope", - get: (g) => g.cut_slope_ratio, - set: (g, v) => (g.cut_slope_ratio = v), - }, - { - label: "B06_Std_Field_FillSlope", - get: (g) => g.fill_slope_ratio, - set: (g, v) => (g.fill_slope_ratio = v), - }, { label: "B06_Std_Field_CrossSlopeMin", + scope: "paved", get: (g) => g.cross_slope_pct.min, set: (g, v) => (g.cross_slope_pct.min = v), }, { label: "B06_Std_Field_CrossSlopeMax", + scope: "paved", get: (g) => g.cross_slope_pct.max, set: (g, v) => (g.cross_slope_pct.max = v), }, ]; +/** 공통 필드 하나를 그룹들에 펼쳐 넣는다(자기 값을 갖는 그룹은 건너뛴다). */ +function spread(state: StandardCrossSection, spec: NumberFieldSpec, value: number): void { + for (const key of ["soil", "rock", "paved"] as StandardCrossKey[]) { + if (spec.exclude === key) continue; + const group = state[key]; + if (group) spec.set(group, value); + } +} + +/** + * 그룹마다 공통값이 다르게 저장돼 있을 수 있다(옛 프로젝트) — **토사 값을 기준**으로 + * 한 벌로 맞춘다(2026-09-04 사용자 확정). 화면은 값 하나를 보이는데 저장분이 셋으로 + * 갈려 있으면 어느 값이 나갔는지 알 수 없기 때문이다. + */ +function unifyCommon(state: StandardCrossSection): void { + const soil = state.soil; + if (!soil) return; + for (const spec of FIELD_SPECS) { + if (spec.scope !== "common") continue; + spread(state, spec, spec.get(soil)); + } +} + /** * 표준 횡단면 설정 패널을 만든다. * @param projectId 세션 캐시 스코프. @@ -258,49 +322,73 @@ export function createStandardPanel( const persist = (): void => writeSession(projectId, state); - const buildGroup = (key: StandardCrossKey, legendKey: keyof typeof ui_locales): HTMLElement => { - const group = state[key]; - // B05 "기준값 직접 지정"과 동일한 details/summary 패턴, 기본 접힘(N-4-1). - const fieldset = document.createElement("details"); - fieldset.className = "b06-std__group"; - const legend = document.createElement("summary"); - legend.className = "b06-std__legend"; - legend.textContent = L(legendKey); - fieldset.append(legend); + /** 필드 한 칸. 공통은 토사 값을 보이고, 고치면 세 그룹에 함께 펼친다. */ + const buildField = (spec: NumberFieldSpec, grid: HTMLElement): void => { + const source = spec.scope === "common" ? state.soil : state[spec.scope]; + if (!source) return; + const field = createInputField({ + label: L(spec.label), + type: "number", + value: String(spec.get(source)), + onInput: (raw) => { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return; + if (spec.scope === "common") spread(state, spec, parsed); + else spec.set(source, parsed); + persist(); + }, + }); + field.input.step = "0.1"; + field.input.min = "0"; + grid.append(field.root); + }; + /** 구분선 + 구간 이름 — 아래 값들이 그 구간에서만 쓰인다는 표시. */ + const buildDivider = (labelKey: keyof typeof ui_locales): HTMLElement => { + const divider = document.createElement("p"); + divider.className = "b06-std__divider"; + divider.textContent = L(labelKey); + return divider; + }; + + const buildScope = (scope: FieldScope): HTMLElement => { const grid = document.createElement("div"); grid.className = "b06-std__grid"; for (const spec of FIELD_SPECS) { - if (spec.only && spec.only !== key) continue; - const field = createInputField({ - label: L(spec.label as keyof typeof ui_locales), - type: "number", - value: String(spec.get(group)), - onInput: (raw) => { - const parsed = Number(raw); - if (!Number.isFinite(parsed)) return; - spec.set(group, parsed); - persist(); - }, - }); - field.input.step = "0.1"; - field.input.min = "0"; - grid.append(field.root); + if (spec.scope === scope) buildField(spec, grid); } - fieldset.append(grid); + return grid; + }; - if (key === "rock") { - const note = document.createElement("p"); - note.className = "b06-std__note"; - note.textContent = L("B06_Std_LType_Note"); - fieldset.append(note); - } + /** 「표준횡단면 상세값」 한 컨테이너 — 공통 → 암 → 포장 순, 구분선으로 나눈다. */ + const buildDetails = (): HTMLElement => { + const fieldset = document.createElement("details"); + fieldset.className = "b06-std__group"; + fieldset.open = true; + const legend = document.createElement("summary"); + legend.className = "b06-std__legend"; + legend.textContent = L("B06_Std_Detail_Title"); + const note = document.createElement("p"); + note.className = "b06-std__note"; + note.textContent = L("B06_Std_LType_Note"); + fieldset.append( + legend, + buildScope("common"), + buildDivider("B06_Std_Section_RockOnly"), + buildScope("rock"), + buildDivider("B06_Std_Section_PavedOnly"), + buildScope("paved"), + note, + ); return fieldset; }; const renderBody = (): void => { - body.replaceChildren(...GROUP_ORDER.map(([key, legendKey]) => buildGroup(key, legendKey))); + body.replaceChildren(buildDetails()); }; + // 옛 저장분이 그룹마다 다른 공통값을 갖고 있으면 토사 기준으로 한 벌로 맞춘다. + unifyCommon(state); + persist(); renderBody(); /** 소스 표준값을 현재 상태에 전부 덮어쓴다(사용자가 "적용"을 눌렀을 때만 호출). */ @@ -308,6 +396,7 @@ export function createStandardPanel( (Object.keys(source) as StandardCrossKey[]).forEach((key) => { if (source[key]) state[key] = JSON.parse(JSON.stringify(source[key])) as StandardCrossGroup; }); + unifyCommon(state); persist(); renderBody(); }; @@ -324,6 +413,7 @@ export function createStandardPanel( (Object.keys(fresh) as StandardCrossKey[]).forEach((key) => { state[key] = fresh[key]; }); + unifyCommon(state); persist(); renderBody(); }, @@ -354,6 +444,7 @@ export function createStandardPanel( (Object.keys(stored) as StandardCrossKey[]).forEach((key) => { if (stored[key]) state[key] = JSON.parse(JSON.stringify(stored[key])) as StandardCrossGroup; }); + unifyCommon(state); renderBody(); }, }; diff --git a/B06_Section/B06_Section_UI_Style.css b/B06_Section/B06_Section_UI_Style.css index bc473aed..e405ef0a 100644 --- a/B06_Section/B06_Section_UI_Style.css +++ b/B06_Section/B06_Section_UI_Style.css @@ -287,6 +287,17 @@ gap: var(--spacing-8); } +/* 구간 구분선 — 「표준횡단면 상세값」 한 컨테이너 안에서 공통 / 암 / 포장을 가른다 + (2026-09-04 사용자 지시: 공통 항목은 지우고 구분선을 쓸 것). */ +.b06-std__divider { + margin: var(--spacing-4) 0 0; + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); + font-size: var(--text-caption); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); +} + .b06-std__note { margin: 0; font-size: var(--text-caption); diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 5877b4ab..3e21a402 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -506,6 +506,10 @@ export const ui_locales_b2 = { B06_Std_Group_Soil: ["토사 구간", "Soil section"], B06_Std_Group_Rock: ["암 구간 (리핑/발파)", "Rock section (ripping/blasting)"], B06_Std_Group_Paved: ["포장 구간", "Paved section"], + B06_Std_Detail_Title: ["표준횡단면 상세값", "Standard cross-section details"], + B06_Std_Section_Common: ["공통", "Common"], + B06_Std_Section_RockOnly: ["암 구간 — 다른 값만", "Rock section - differing values"], + B06_Std_Section_PavedOnly: ["포장 구간 — 다른 값만", "Paved section - differing values"], B06_Std_Field_RoadWidth: ["노폭(m)", "Road width (m)"], B06_Std_Field_ShoulderLeft: ["노견 좌(m)", "Shoulder L (m)"], B06_Std_Field_ShoulderRight: ["노견 우(m)", "Shoulder R (m)"], From 99fa98a0ddea88594eb620054e2ba58ebe4a94f9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 18:43:49 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat(B05):=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=ED=99=95=EB=8C=80=20=E2=80=94=20=EB=B0=B0?= =?UTF-8?q?=EC=88=98=EC=9C=A0=EC=97=AD=20=EC=82=B0=EC=B6=9C=EB=AC=BC?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=20=EC=B4=88=EA=B8=B0=EA=B0=92=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=B4=AC=EC=98=81=C2=B7=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스냅샷 대상이 B04_PreProcess/drainage/edits 에서 drainage 폴더 통째로 넓어짐 (00_watershed_response ~ 04_detailed_basins 포함). 관을 옮겨 다시 나뉜 세부유역이 초기화로 되돌아가지 않던 구멍을 막음 - 용량 실측: 배수유역 8.6MB, 스냅샷 전체 3.9MB → 약 12MB - 옛 스냅샷(edits 만 촬영) 호환 — 그 경우 예전처럼 관 지점 편집분만 복원 - 3D 코리도는 제외 — 스냅샷 시점(체인 직후)에 아직 없는 값이라 별도 판단 필요 Co-Authored-By: Claude Opus 5 (1M context) --- common_util/common_util_initial_snapshot.py | 23 ++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/common_util/common_util_initial_snapshot.py b/common_util/common_util_initial_snapshot.py index 90eadb9c..9b68e25c 100644 --- a/common_util/common_util_initial_snapshot.py +++ b/common_util/common_util_initial_snapshot.py @@ -30,13 +30,21 @@ DESIGNING_LOCK_NAME = "initial_design.lock" DESIGN_FAILED_NAME = "initial_design.failed" # 프로젝트 루트 기준 상대 경로 — 정본 파일이 사는 자리 전부. +# +# 배수유역은 `edits/`(관 지점 편집분)만 뜨다가 **폴더 통째**로 넓혔다(2026-09-04 사용자 +# 확정: 「초기값 = 파일 입력 직후 결과 전부」). 관을 옮기면 세부유역(`04_detailed_basins`)이 +# 다시 나뉘는데 그 산출물이 스냅샷 밖이라 [초기화]가 옛 유역도를 그대로 남겼다. +# 용량은 실측 8.6MB(스냅샷 전체 3.9MB → 약 12MB)로 감당할 만하다. _FILE_TREES = ( "B05_Profile/route", "B06_Section/longitudinal", "B06_Section/cross_sections", - "B04_PreProcess/drainage/edits", + "B04_PreProcess/drainage", ) +# 배수유역을 폴더 통째로 넓히기 전(2026-09-04)에 찍힌 스냅샷이 갖고 있는 자리. +_LEGACY_DRAINAGE_TREE = "B04_PreProcess__drainage__edits" + # `routes.id`를 참조하는 표는 스키마상 이 넷이 전부다(001_create_schema.sql:539~556). _CHILD_TABLES = ("route_points", "route_statistics", "longitudinal_sections", "cross_sections") @@ -238,11 +246,20 @@ def wipe_edited_masters(project_root: Path) -> list[str]: def restore_snapshot_files(project_root: Path) -> None: - """스냅샷 파일을 작업본 자리에 되돌린다. 스냅샷에 없던 트리는 비워 둔다.""" + """스냅샷 파일을 작업본 자리에 되돌린다. 스냅샷에 없던 트리는 비워 둔다. + + 배수유역 범위를 넓히기 전(2026-09-04)에 찍힌 스냅샷은 `edits/`만 갖고 있다 — + 그런 프로젝트는 예전처럼 그 자리만 되돌린다. 넓힌 트리를 못 찾았다고 그냥 넘어가면 + 관 지점 편집분이 초기화 뒤에도 남는다. + """ root = Path(project_root) source = snapshot_dir(root) for tree in _FILE_TREES: - _copy_tree(source / tree.replace("/", "__"), root / tree) + stored = source / tree.replace("/", "__") + if not stored.is_dir() and tree == "B04_PreProcess/drainage": + _copy_tree(source / _LEGACY_DRAINAGE_TREE, root / "B04_PreProcess/drainage/edits") + continue + _copy_tree(stored, root / tree) async def restore_initial_snapshot( From 52031917519d76f43bf232d44b76283da4331c18 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 19:13:46 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat(B05):=20=EC=A7=84=EC=9E=85=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=203D=20=ED=9B=84=EC=88=9C=EC=9C=84=20=C2=B7=20?= =?UTF-8?q?=EB=8F=84=EB=84=9B=20=ED=9A=8C=EC=A0=84=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=C2=B7=20=EC=A7=81=EA=B5=90/=EC=9B=90=EA=B7=BC=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=8B=A8=EC=B6=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-04) — 「3D 는 보조라 후순위. 조용히 뒤에서 뜨고 다 되면 팝업으로 알릴 것. 로딩 도넛은 진행률과 무관하게 계속 돌고 숫자만 갱신」, 그리고 「직교/원근 전환 단추는 넣음」. 진입 로딩 - 단계 5 → 4. ④ 종·횡단 자료까지 끝나면 로딩 표시를 걷어 화면을 바로 쓰게 함. - 3D 지형은 finally 뒤 배경 작업으로 돌리고 끝나면 토스트 「3D 지형 준비 완료」. 실패해도 나머지 화면은 그대로 씀. [초기화]는 재진입이라 같은 규칙이 걸림. - 「확정 지표면 없음」 판정을 ③ 뒤로 앞당김. 진행 서클(공용) - 회전 껍데기(`__spin`)를 한 겹 두고 CSS 회전을 거기에만 검. 껍데기는 늘 돌고 안쪽 svg 는 12시 고정이라 호·숫자가 제자리에서 갱신됨. `is-indeterminate` 제거. 직교/원근 전환 - 카메라 두 벌을 `B05_Profile_UI_Viewer_Camera.ts` 가 쥐고 갈아 끼움. 위치·시선· 근평면·먼평면과 보이는 크기를 옮겨 화면이 튀지 않음. - 마커 입력·구조물 클릭·커서 피벗 유틸이 카메라를 함수로 받게 바꿈(공용 유틸은 값도 그대로 받아 B04 호출부는 무변경). 자체검증(공용 브라우저 5173, 용화_LAS) — 진행률 25→50→75%, 회전 애니메이션 running 유지(변환행렬 표본 전부 다름), 로딩 걷힌 순간 그래프선 2·좌측단추 61· 3D 메쉬 2, 1.8초 뒤 메쉬 489 + 토스트. 초기화 재진입도 같음. 전환 단추는 Ortho ↔ Perspective 왕복 후 값 완전 일치, B04 페이지 오류 0건. Co-Authored-By: Claude Opus 5 --- B04_PreProcess/B04_PreProcess_UI_Camera.ts | 16 +++- B05_Profile/B05_Profile_UI_Page.ts | 62 +++++++------- B05_Profile/B05_Profile_UI_Panel.ts | 15 ++++ B05_Profile/B05_Profile_UI_Viewer.ts | 40 +++++---- B05_Profile/B05_Profile_UI_Viewer_Camera.ts | 81 +++++++++++++++++++ .../B05_Profile_UI_Viewer_Marker_Input.ts | 7 +- .../B05_Profile_UI_Viewer_Structure_Pick.ts | 5 +- ui_template/ui_template_progress.css | 18 +++-- ui_template/ui_template_progress.ts | 13 ++- 9 files changed, 193 insertions(+), 64 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Viewer_Camera.ts diff --git a/B04_PreProcess/B04_PreProcess_UI_Camera.ts b/B04_PreProcess/B04_PreProcess_UI_Camera.ts index 7a40a3a5..b6c20751 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Camera.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Camera.ts @@ -120,7 +120,11 @@ function pivotReticleTexture(): THREE.CanvasTexture { } export interface CursorPivotOptions { - camera: THREE.PerspectiveCamera | THREE.OrthographicCamera; + /** 카메라. **바뀔 수 있으면 함수로** 준다 — B05는 직교/원근을 갈아 끼운다(2026-09-04). */ + camera: + | THREE.PerspectiveCamera + | THREE.OrthographicCamera + | (() => THREE.PerspectiveCamera | THREE.OrthographicCamera); controls: OrbitControls; /** 포인터 이벤트를 받는 캔버스. */ element: HTMLElement; @@ -134,7 +138,9 @@ export interface CursorPivotOptions { /** 커서 기준 회전·줌을 붙이고, 해제 함수를 돌려준다. */ export function bindCursorPivotControls(options: CursorPivotOptions): () => void { - const { camera, controls, element } = options; + const { controls, element } = options; + const getCamera = (): THREE.PerspectiveCamera | THREE.OrthographicCamera => + typeof options.camera === "function" ? options.camera() : options.camera; // 회전·줌 모두 여기서 직접 처리한다(OrbitControls에는 휠 방향을 뒤집는 설정이 없다). controls.enableRotate = false; controls.enableZoom = false; @@ -165,6 +171,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void /** 조준점을 현재 축 위치·크기로 맞춘다. 스프라이트 scale = 쿼드의 월드 폭. */ function syncPivotMarker(): void { + const camera = getCamera(); if (!pivotMarker || !pivotMarker.visible) return; pivotMarker.position.copy(pivot); const distance = camera.position.distanceTo(pivot); @@ -190,6 +197,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void * 지형을 맞히면 그 점을 쓰고, 하늘·구멍이라 못 맞히면 시선에 수직이고 현재 target을 * 지나는 평면과 광선을 만나게 해 **커서 방향**의 점을 쓴다(화면 중앙으로 돌아가지 않는다). */ function pickPivot(event: { clientX: number; clientY: number }): void { + const camera = getCamera(); pivot.copy(controls.target); const rect = element.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; @@ -219,7 +227,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void // 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다. pickPivot(event); panAnchor.copy(pivot); - camera.getWorldDirection(viewDirection); + getCamera().getWorldDirection(viewDirection); panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor); panPointerId = event.pointerId; element.setPointerCapture?.(event.pointerId); @@ -243,6 +251,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void /** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다. * 휠을 위로 올리면 멀어진다(사용자 지시). */ function onWheel(event: WheelEvent): void { + const camera = getCamera(); if (!controls.enabled || options.blocked?.()) return; event.preventDefault(); pickPivot(event); @@ -265,6 +274,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void } function onPointerMove(event: PointerEvent): void { + const camera = getCamera(); if (panPointerId === event.pointerId) { // 그랩 팬 — 잡은 점이 커서 아래에 계속 오도록 카메라·target을 평행 이동한다. const rect = element.getBoundingClientRect(); diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 10bee531..e35323b4 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -257,6 +257,7 @@ export async function renderB05Route(root: HTMLElement): Promise { onSurfaceGrayscale: viewer.setSurfaceGrayscale, onView: viewer.setView, onResetView: () => viewer.setView("top"), + onProjection: viewer.setProjection, // [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다. onCorridorRefresh: async () => { if (!currentSectionDetail || !latest?.route?.id) return; @@ -581,12 +582,11 @@ export async function renderB05Route(root: HTMLElement): Promise { }; /* ── 진입 로딩 ───────────────────────────────────────────────────────── - * 전부 받아 놓고 한 번에 그리면 몇 초 동안 빈 화면만 보인다. 화면 틀을 먼저 띄우고 - * 자료가 끝나는 순서대로 채운다. 3D 지형이 가장 느리므로 맨 마지막에 올리고, 그동안 - * 3D 뷰포트에 공통 프로그레스 서클을 띄운다(2026-08-01 사용자 지시). */ - const LOAD_STEP_COUNT = 5; - // 3D 뷰포트 정중앙. 하단 종단 패널(z-index 3)보다 아래라 패널에 가려지는 것은 무방하다 - // (2026-08-01 사용자 지시). + * 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시). + * 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시) — 네 단계가 + * 끝나면 로딩 표시를 걷어 화면을 바로 쓰게 하고, 3D는 뒤에서 올린 뒤 알린다. + * 서클은 3D 뷰포트 정중앙에 둔다(하단 종단 패널에 가려지는 것은 무방). */ + const LOAD_STEP_COUNT = 4; const progress = createProgressCircle({ label: "화면 틀을 준비하는 중…", overlay: true }); viewer.root.append(progress.root); let loadedSteps = 0; @@ -654,33 +654,15 @@ export async function renderB05Route(root: HTMLElement): Promise { // ③ 확정 지표면 모델 목록. const models = await listSurfaceModels(activeProjectId); confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null; - advanceLoading("종단면 자료를 불러오는 중…"); - - // ④ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. - if (latestResponse.route) await restoreSections(latestResponse.route.id); - advanceLoading("3D 지형을 불러오는 중…"); - - // ⑤ 3D 지형 — 가장 무거우므로 맨 마지막. if (!confirmedSurface) { // 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다. leaveForDashboard(); return; - } else { - // 지형 가장자리만 필요하다 — 포인트클라우드 전체(수십 MB)는 받지 않는다. - const confirmed = await fetchConfirmedSurface(activeProjectId); - if (!confirmed.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); - await viewer.loadSurface( - activeProjectId, - confirmedSurface.id, - latestResponse.surface_params.method, - latestResponse.surface_params.smooth, - latestResponse.surface_params.contour_interval_m, - toBounds(confirmed.bounds), - ); - // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. - renderLatest(latestResponse); - if (currentSectionDetail) renderStationLines(currentSectionDetail); } + advanceLoading("종단면 자료를 불러오는 중…"); + + // ④ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다. + if (latestResponse.route) await restoreSections(latestResponse.route.id); // 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후). await bridge.load(); advanceLoading(""); @@ -691,4 +673,28 @@ export async function renderB05Route(root: HTMLElement): Promise { restoring = false; restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더. } + + // ⑤ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다. + void (async () => { + const [surface, current] = [confirmedSurface, latest]; + if (!surface || !current) return; + try { + // 가장자리만 받는다 — 포인트클라우드 전체(수십 MB)는 안 받는다. + const confirmed = await fetchConfirmedSurface(activeProjectId); + if (!confirmed.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); + await viewer.loadSurface( + activeProjectId, + surface.id, + current.surface_params.method, + current.surface_params.smooth, + current.surface_params.contour_interval_m, + toBounds(confirmed.bounds), + ); + renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. + if (currentSectionDetail) renderStationLines(currentSectionDetail); + showToast("3D 지형 준비 완료", "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error"); + } + })(); } diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index a9450ea7..905b0ca2 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -104,6 +104,8 @@ interface PanelCallbacks { /** 지표면 흑백 표시 토글(기본 꺼짐 — 무지개 고도색). */ onSurfaceGrayscale: (grayscale: boolean) => void; onView: (view: "iso" | "top" | "front" | "side") => void; + /** 직교/원근 전환 — 탑뷰에서 크기를 정밀 대조할 때만 직교로 본다(2026-09-04 사용자 지시). */ + onProjection: (kind: "perspective" | "ortho") => void; onResetView: () => void; /** [3D 업데이트] — 계획선 편집을 3D 예상형상·측점선에 한 번에 반영(2026-09-01 사용자 * 지시). 편집마다 따라오던 자동 갱신을 없애고 이 버튼으로만 돌린다. */ @@ -220,6 +222,19 @@ export function createRoutePanel(callbacks: PanelCallbacks) { (["iso", "top", "front", "side"] as const).forEach((preset) => viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")), ); + // 직교/원근 전환(2026-09-04 사용자 지시) — 기본은 원근이고, 단추 글자는 **바뀔 쪽**을 + // 가리킨다(누르면 그쪽으로 간다). + let projection: "perspective" | "ortho" = "perspective"; + const projectionButton = button( + "직교로", + () => { + projection = projection === "perspective" ? "ortho" : "perspective"; + projectionButton.textContent = projection === "perspective" ? "직교로" : "원근으로"; + callbacks.onProjection(projection); + }, + "glass", + ); + viewButtons.append(projectionButton); const visibilityButtons = document.createElement("div"); visibilityButtons.className = "b05-route__view-group"; visibilityButtons.append( diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index c09b1096..09b5674e 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -14,6 +14,7 @@ import { type RouteMarkers, type SectionStationMarker, } from "./B05_Profile_UI_Markers"; +import { createCameraRig, type ProjectionKind } from "./B05_Profile_UI_Viewer_Camera"; import { bindMarkerPointerControls } from "./B05_Profile_UI_Viewer_Marker_Input"; import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import { @@ -117,6 +118,8 @@ export interface RouteViewer { setCorridorVisible: (visible: boolean) => void; renderStationLines: (stations: SectionStationMarker[], halfWidth: number) => void; setView: (view: "iso" | "top" | "front" | "side") => void; + /** 직교/원근 전환(2026-09-04) — 보이는 크기를 유지한 채 카메라만 갈아 끼운다. */ + setProjection: (kind: ProjectionKind) => void; beginMoveSelected: () => void; /** 화면(client) 좌표 아래 지형의 모델 좌표 — 3D 우클릭 구조물 배치용(2026-08-19). */ modelPointAt: (clientX: number, clientY: number) => { x: number; y: number; z: number } | null; @@ -164,13 +167,12 @@ export function createRouteViewer(): RouteViewer { }); systemDarkTheme.addEventListener("change", updateSceneBackground); updateSceneBackground(); - // 원근 카메라(시야각 45°) — 2026-09-04 사용자 지시로 직교에서 되돌렸다. 직교가 - // 필요했던 탑뷰 구조물 투영 윤곽선은 2026-09-02에 숨김 처리되어 화면에 없다. - const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100000); - camera.position.set(100, 120, 100); + // 카메라는 원근(기본)·직교 두 벌을 두고 갈아 끼운다 — 갈아 끼우면 **객체가 바뀌므로** + // 붙잡아 두지 말고 `cameraRig.camera()`로 그때그때 읽는다(2026-09-04 사용자 지시). + const cameraRig = createCameraRig(); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); - const controls = new OrbitControls(camera, canvas); + const controls = new OrbitControls(cameraRig.camera(), canvas); controls.enableDamping = true; scene.add(new THREE.HemisphereLight(0xffffff, 0x64748b, 2.2)); const directional = new THREE.DirectionalLight(0xffffff, 2.2); @@ -216,7 +218,9 @@ export function createRouteViewer(): RouteViewer { THREE, // 카메라도 함께 낸다(2026-09-04) — 화면 좌표에서 레이캐스트로 무엇이 앞에 있는지 // 확인해야 3D 클릭 검증을 수치로 할 수 있다. - camera, + get camera() { + return cameraRig.camera(); + }, toScene: (x: number, y: number, z: number) => bounds ? modelToScene({ x, y, z }, bounds) : null, topZ: () => (bounds ? bounds.z[1] + 100 : null), @@ -231,7 +235,7 @@ export function createRouteViewer(): RouteViewer { // 눌러 검증할 때 쓴다. 캔버스가 아래 패널에 가려 중앙이 안 보이므로 자리를 직접 잰다. project: (x: number, y: number, z: number) => { if (!bounds) return null; - const point = modelToScene({ x, y, z }, bounds).project(camera); + const point = modelToScene({ x, y, z }, bounds).project(cameraRig.camera()); const rect = canvas.getBoundingClientRect(); return { x: rect.left + ((point.x + 1) / 2) * rect.width, @@ -243,7 +247,7 @@ export function createRouteViewer(): RouteViewer { // 캔버스 포인터 입력(마커 끌기·고르기·끌어놓기)은 따로 뗐다(2026-09-02, 700줄 제한). const markerInput = bindMarkerPointerControls({ canvas, - camera, + camera: cameraRig.camera, controls, markers, getTerrain: () => terrain, @@ -252,14 +256,14 @@ export function createRouteViewer(): RouteViewer { // 코리도 구조물 클릭 선택(2026-09-04) — 마커보다 뒤 순위다. const structurePick = bindStructurePick({ canvas, - camera, + camera: cameraRig.camera, group: () => corridorGroup, blocked: () => markerInput.blocked(), }); // 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸). // 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다. const releaseCursorPivot = bindCursorPivotControls({ - camera, + camera: cameraRig.camera, controls, element: canvas, pickables: () => (terrain ? [terrain] : []), @@ -276,8 +280,7 @@ export function createRouteViewer(): RouteViewer { const width = Math.max(1, root.clientWidth); const height = Math.max(1, root.clientHeight); renderer.setSize(width, height, false); - camera.aspect = width / height; - camera.updateProjectionMatrix(); + cameraRig.setAspect(width / height); } const resizeObserver = new ResizeObserver(resize); resizeObserver.observe(root); @@ -292,6 +295,7 @@ export function createRouteViewer(): RouteViewer { side: [distance, distance * 0.25, 0], } as const; const [x, y, z] = positions[view]; + const camera = cameraRig.camera(); camera.position.set(target.x + x, target.y + y, target.z + z); // 근평면 상한 0.4m — 휠 확대는 커서 아래 지점 0.5m 앞에서 멈춘다(커서 피벗 유틸). // 거리에만 비례시키면 긴 노선(맞춤 거리 1km 이상)에서 근평면이 그 0.5m를 넘어 @@ -299,6 +303,7 @@ export function createRouteViewer(): RouteViewer { camera.near = Math.max(0.1, Math.min(0.4, distance / 1000)); camera.far = distance * 10; camera.updateProjectionMatrix(); + cameraRig.setFit(distance); controls.update(); } @@ -360,16 +365,16 @@ export function createRouteViewer(): RouteViewer { if (terrain) { compass.setVisible(true); compass.update( - camera.position.x - controls.target.x, - camera.position.y - controls.target.y, - camera.position.z - controls.target.z, + cameraRig.camera().position.x - controls.target.x, + cameraRig.camera().position.y - controls.target.y, + cameraRig.camera().position.z - controls.target.z, ); } else { compass.setVisible(false); } // 측점 라벨 솎기 — 가까울수록 촘촘히 보인다(단계가 안 바뀌면 모듈 안에서 걸러낸다). - markers.updateLabelDetail(camera.position.distanceTo(controls.target)); - renderer.render(scene, camera); + markers.updateLabelDetail(cameraRig.camera().position.distanceTo(controls.target)); + renderer.render(scene, cameraRig.camera()); } animate(); @@ -665,6 +670,7 @@ export function createRouteViewer(): RouteViewer { setStationLabelsVisible: markers.setStationLabelsVisible, renderStationLines: markers.renderStationLines, setView: fit, + setProjection: (kind) => cameraRig.setKind(kind, controls), beginMoveSelected() { markerInput.beginMoveSelected(); status.textContent = "선택한 포인트를 이동할 지형 위치를 클릭하세요."; diff --git a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts new file mode 100644 index 00000000..32f94f3f --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts @@ -0,0 +1,81 @@ +/* ============================================================================= + * B05_Profile_UI_Viewer_Camera.ts + * B05 뷰어의 **원근/직교 두 카메라**와 그 사이 갈아 끼우기. + * + * 기본은 원근(시야각 45°) — B04 지표면 화면과 같은 조작감이다(2026-09-04 사용자 확정). + * 탑뷰에서 크기를 정밀하게 대조할 때만 직교로 바꾼다. 갈아 끼울 때 위치·시선·근평면· + * 먼평면과 **보이는 크기**를 그대로 옮기므로 화면이 튀지 않는다. + * + * 카메라 객체가 바뀌므로 쓰는 쪽은 붙잡아 두지 말고 `camera()`로 그때그때 읽을 것. + * ========================================================================== */ + +import * as THREE from "three"; +import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; + +export type ProjectionKind = "perspective" | "ortho"; + +const FOV = 45; +/** 원근 45°의 반각 tan — 직교 반높이를 같은 크기감으로 맞출 때 쓴다. */ +const HALF_TAN = Math.tan((FOV * Math.PI) / 360); + +export function createCameraRig() { + const perspective = new THREE.PerspectiveCamera(FOV, 1, 0.1, 100000); + perspective.position.set(100, 120, 100); + const ortho = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 100000); + let kind: ProjectionKind = "perspective"; + let aspect = 1; + let halfHeight = 100; + + const active = (): THREE.PerspectiveCamera | THREE.OrthographicCamera => + kind === "perspective" ? perspective : ortho; + + function apply(): void { + perspective.aspect = aspect; + perspective.updateProjectionMatrix(); + ortho.left = -halfHeight * aspect; + ortho.right = halfHeight * aspect; + ortho.top = halfHeight; + ortho.bottom = -halfHeight; + ortho.updateProjectionMatrix(); + } + + return { + camera: active, + kind: () => kind, + /** 뷰포트 종횡비(리사이즈 시). */ + setAspect(value: number): void { + aspect = value; + apply(); + }, + /** 화면맞춤 — 시점까지 거리로 직교 반높이를 잡는다(원근과 같은 크기감). */ + setFit(distance: number): void { + halfHeight = distance * HALF_TAN; + ortho.zoom = 1; + apply(); + }, + /** 투영 전환. 보이는 크기를 유지하며 OrbitControls의 대상 카메라도 갈아 끼운다. */ + setKind(next: ProjectionKind, controls: OrbitControls): void { + if (next === kind) return; + const from = active(); + kind = next; + const to = active(); + to.quaternion.copy(from.quaternion); + to.near = from.near; + to.far = from.far; + const offset = from.position.clone().sub(controls.target); + if (next === "ortho") { + halfHeight = offset.length() * HALF_TAN; + ortho.zoom = 1; + to.position.copy(from.position); + } else { + // 직교는 배율(zoom)로도 커지므로, 같은 크기로 보이는 거리까지 카메라를 물린다. + to.position.copy(controls.target).add(offset.setLength(halfHeight / ortho.zoom / HALF_TAN)); + } + apply(); + controls.object = to; + controls.update(); + }, + }; +} + +export type CameraRig = ReturnType; diff --git a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts index 6ff036bb..afeb0ab9 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts @@ -30,7 +30,8 @@ export interface MarkerPointerControls { export function bindMarkerPointerControls(options: { canvas: HTMLCanvasElement; - camera: THREE.Camera; + /** 카메라 조회 — 뷰어가 원근/직교를 갈아 끼우므로 값이 아니라 함수로 받는다. */ + camera: () => THREE.Camera; controls: OrbitControls; markers: RouteMarkers; getTerrain: () => THREE.Object3D | null; @@ -60,7 +61,7 @@ export function bindMarkerPointerControls(options: { const bounds = getBounds(); if (!terrain || !bounds) return null; const raycaster = new THREE.Raycaster(); - raycaster.setFromCamera(pointerOf(clientX, clientY), camera); + raycaster.setFromCamera(pointerOf(clientX, clientY), camera()); const hit = raycaster.intersectObject(terrain, true)[0]; return hit ? sceneToModel(hit.point, bounds) : null; } @@ -84,7 +85,7 @@ export function bindMarkerPointerControls(options: { function markerHit(event: PointerEvent): THREE.Object3D | undefined { const raycaster = new THREE.Raycaster(); - raycaster.setFromCamera(pointerOf(event.clientX, event.clientY), camera); + raycaster.setFromCamera(pointerOf(event.clientX, event.clientY), camera()); return raycaster.intersectObject(markers.group, true)[0]?.object; } diff --git a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts index 3e2688e4..4235b2e4 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts @@ -39,7 +39,8 @@ const CLICK_SLOP_PX = 3; export function bindStructurePick(options: { canvas: HTMLCanvasElement; - camera: THREE.Camera; + /** 카메라 조회 — 뷰어가 원근/직교를 갈아 끼우므로 값이 아니라 함수로 받는다. */ + camera: () => THREE.Camera; /** 코리도 그룹 — 없거나 꺼져 있으면 고르지 않는다. */ group: () => THREE.Object3D | null; /** 마커를 잡고 있거나 이동 대기 중인가 — 참이면 구조물 선택을 건너뛴다. */ @@ -102,7 +103,7 @@ export function bindStructurePick(options: { ((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1, ), - camera, + camera(), ); // 모서리 선(LineSegments)은 뺀다 — 라인 레이캐스트 허용반경이 1m라 클릭을 가로챈다. const hit = raycaster diff --git a/ui_template/ui_template_progress.css b/ui_template/ui_template_progress.css index 9deb740c..498ad60b 100644 --- a/ui_template/ui_template_progress.css +++ b/ui_template/ui_template_progress.css @@ -25,6 +25,15 @@ height: var(--ui-progress-size); } +/* 회전 껍데기 — 진행률을 알든 모르든 **항상** 돈다(2026-09-04 사용자 지시). + 무거운 단계에서 호가 안 늘어도 도넛이 멈춰 보이지 않는다. 안쪽 svg는 12시 고정이라 + 호·숫자는 제자리에서 갱신된다. */ +.ui-progress-circle__spin { + width: 100%; + height: 100%; + animation: ui-progress-spin 1s linear infinite; +} + .ui-progress-circle__svg { width: 100%; height: 100%; @@ -46,17 +55,12 @@ transition: stroke-dashoffset var(--transition-base); } -/* 진행률을 모르는 구간 — 호 하나를 계속 돌린다. */ -.ui-progress-circle.is-indeterminate .ui-progress-circle__svg { - animation: ui-progress-spin 1s linear infinite; -} - @keyframes ui-progress-spin { from { - transform: rotate(-90deg); + transform: rotate(0deg); } to { - transform: rotate(270deg); + transform: rotate(360deg); } } diff --git a/ui_template/ui_template_progress.ts b/ui_template/ui_template_progress.ts index 00c12cfb..048116cc 100644 --- a/ui_template/ui_template_progress.ts +++ b/ui_template/ui_template_progress.ts @@ -61,22 +61,27 @@ export function createProgressCircle(options: ProgressCircleOptions = {}): Progr label.className = "ui-progress-circle__label"; label.textContent = options.label ?? ""; + // 회전은 진행률과 **따로 논다**(2026-09-04 사용자 지시) — 무거운 단계에서 호가 + // 안 늘어도 도넛은 계속 돌아야 "멈춘 것"으로 안 보인다. 바깥 껍데기만 CSS로 돌리고 + // 안쪽 svg는 12시 고정이라, 호·숫자는 제자리에서 갱신된다. + const spin = document.createElement("div"); + spin.className = "ui-progress-circle__spin"; + spin.append(svg); + const dial = document.createElement("div"); dial.className = "ui-progress-circle__dial"; - dial.append(svg, percent); + dial.append(spin, percent); root.append(dial, label); function set(ratio: number | null, nextLabel?: string): void { if (nextLabel !== undefined) label.textContent = nextLabel; if (ratio === null) { - // 진행률 미상 — 4분의 1 호를 돌려 "돌아가는 중"만 알린다. - root.classList.add("is-indeterminate"); + // 진행률 미상 — 4분의 1 호만 남긴다(회전은 껍데기가 늘 맡는다). bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * 0.75)); percent.textContent = ""; return; } const clamped = Math.min(1, Math.max(0, ratio)); - root.classList.remove("is-indeterminate"); bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * (1 - clamped))); percent.textContent = `${Math.round(clamped * 100)}%`; }