Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1

This commit is contained in:
2026-09-04 19:18:02 +09:00
23 changed files with 660 additions and 216 deletions
+13 -3
View File
@@ -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();
+45 -12
View File
@@ -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;
},
+38 -32
View File
@@ -257,6 +257,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
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;
@@ -423,10 +424,10 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
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,
};
@@ -581,12 +582,11 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
};
/* ── 진입 로딩 ─────────────────────────────────────────────────────────
* 전부 받아 놓고 한 번에 그리면 몇 초 동안 빈 화면만 보인다. 화면 틀을 먼저 띄우고
* 자료가 끝나는 순서대로 채운다. 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<void> {
// ③ 확정 지표면 모델 목록.
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<void> {
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");
}
})();
}
+15
View File
@@ -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(
+43 -1
View File
@@ -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(
+35 -6
View File
@@ -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,
+23 -36
View File
@@ -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 }) };
}
+7 -1
View File
@@ -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;
}
+28 -16
View File
@@ -14,7 +14,7 @@ import {
type RouteMarkers,
type SectionStationMarker,
} from "./B05_Profile_UI_Markers";
import { createOrthoCameraRig } from "./B05_Profile_UI_Viewer_Camera";
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 {
@@ -118,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;
@@ -165,11 +167,12 @@ export function createRouteViewer(): RouteViewer {
});
systemDarkTheme.addEventListener("change", updateSceneBackground);
updateSceneBackground();
const cameraRig = createOrthoCameraRig();
const camera = cameraRig.camera;
// 카메라는 원근(기본)·직교 두 벌을 두고 갈아 끼운다 — 갈아 끼우면 **객체가 바뀌므로**
// 붙잡아 두지 말고 `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);
@@ -215,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),
@@ -230,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,
@@ -242,7 +247,7 @@ export function createRouteViewer(): RouteViewer {
// 캔버스 포인터 입력(마커 끌기·고르기·끌어놓기)은 따로 뗐다(2026-09-02, 700줄 제한).
const markerInput = bindMarkerPointerControls({
canvas,
camera,
camera: cameraRig.camera,
controls,
markers,
getTerrain: () => terrain,
@@ -251,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] : []),
@@ -290,11 +295,15 @@ 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);
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();
cameraRig.setFit(distance);
controls.update();
}
@@ -356,14 +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);
}
renderer.render(scene, camera);
// 측점 라벨 솎기 — 가까울수록 촘촘히 보인다(단계가 안 바뀌면 모듈 안에서 걸러낸다).
markers.updateLabelDetail(cameraRig.camera().position.distanceTo(controls.target));
renderer.render(scene, cameraRig.camera());
}
animate();
@@ -659,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 = "선택한 포인트를 이동할 지형 위치를 클릭하세요.";
+64 -25
View File
@@ -1,42 +1,81 @@
/* =============================================================================
* B05_Profile_UI_Viewer_Camera.ts
* B05 뷰어의 **직교(원근 없음) 카메라**(2026-08-25 사용자 확정) — 탑뷰에서 구조물·
* 절단 경계가 원근으로 일그러지지 않는다. 화면 배율은 camera.zoom이 지고(커서 피벗
* 유틸이 조작), 절두체 반높이는 fit()이 정한다. Viewer 700줄 제한으로 분리.
* B05 뷰어의 **원근/직교 두 카메라**와 그 사이 갈아 끼우기.
*
* 기본은 원근(시야각 45°) — B04 지표면 화면과 같은 조작감이다(2026-09-04 사용자 확정).
* 탑뷰에서 크기를 정밀하게 대조할 때만 직교로 바꾼다. 갈아 끼울 때 위치·시선·근평면·
* 먼평면과 **보이는 크기**를 그대로 옮기므로 화면이 튀지 않는다.
*
* 카메라 객체가 바뀌므로 쓰는 쪽은 붙잡아 두지 말고 `camera()`로 그때그때 읽을 것.
* ========================================================================== */
import * as THREE from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
export interface OrthoCameraRig {
camera: THREE.OrthographicCamera;
/** 뷰포트 종횡비 반영(리사이즈 시). */
setAspect(aspect: number): void;
/** 절두체 반높이(월드 m) 지정 — fit()이 화면 배율을 잡을 때 쓴다. zoom은 1로 되돌린다. */
setHalfHeight(halfHeight: number): void;
}
export type ProjectionKind = "perspective" | "ortho";
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;
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;
const apply = (): void => {
camera.left = -halfHeight * aspect;
camera.right = halfHeight * aspect;
camera.top = halfHeight;
camera.bottom = -halfHeight;
camera.updateProjectionMatrix();
};
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,
camera: active,
kind: () => kind,
/** 뷰포트 종횡비(리사이즈 시). */
setAspect(value: number): void {
aspect = value;
apply();
},
setHalfHeight(value: number): void {
halfHeight = value;
camera.zoom = 1;
/** 화면맞춤 — 시점까지 거리로 직교 반높이를 잡는다(원근과 같은 크기감). */
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<typeof createCameraRig>;
@@ -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;
}
@@ -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
+10 -2
View File
@@ -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만큼 더 좁게 잡아,
@@ -223,6 +223,47 @@ export function calculateYScale(
};
}
/**
* ** ·**( , 2026-09-04 ).
*
* Y
* . B05
* B06 .
*
* ** ** .
* .
*/
export function windowElevationRange(
series: ReadonlyArray<ReadonlyArray<{ chainage_m?: number; elevation_m?: number | null }>>,
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";
+44 -6
View File
@@ -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;
@@ -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,
+146 -55
View File
@@ -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();
},
};
+11
View File
@@ -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);
+20 -3
View File
@@ -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(
+43 -2
View File
@@ -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;
+4
View File
@@ -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)"],
+11 -7
View File
@@ -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);
}
}
+9 -4
View File
@@ -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)}%`;
}