Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_View.ts
T
eomsangdonandClaude Opus 5.5 207c2e40eb feat(B05): 계획노선 편집 창 — 측점 사이 기울기 라벨 · 1m 등고선 켬끔 · 보던 화면 기억
- 측점 사이 종단기울기(지반고 %) 라벨 — 측점 간격이 화면 90px 이상일 때만 · 측점 라벨 맞은편 · 회전해도 글 바로 섬 · 끄는 동안 안 물음
- 「1m 등고선」 단추(기본 꺼짐 · LAS 없으면 잠김) — 1m 선 얇고 옅게 · 켰을 때 처음 한 번만 준비 · 5m 선은 한 단 짙게(--map-contour-major)
- 회전 · 가운데 · 배율을 화면 취향(routeedit-view · 프로젝트마다 · 계정에도 올라감)에 적고 다시 열면 그대로
- 700줄 한도 — 화면 셈·기억은 _View, 재기 배선은 _Measure, 바탕 읽기는 _Contour, 노드 끼우기·빼기는 _Edits 로 나눔
- 시험 test_b05_routeedit_view.py (창 크기 달라도 같은 자리 · 저장소 왕복 · 노드 편집값 자리)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
2026-09-29 09:33:57 +09:00

169 lines
6.5 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_View.ts
* 계획노선 편집 모달의 **화면 좌표 셈**과 **보던 화면 기억**(계획서 18 ③).
*
* 기억하는 값은 화면 크기와 무관한 꼴로 둔다 — 가운데 자리(사업지 좌표 m)·1m 당 픽셀·
* 회전각. 창 크기가 달라져도 같은 자리를 같은 배율로 다시 연다(px 오프셋을 그대로 두면
* 창이 바뀔 때 엉뚱한 데가 뜬다).
*
* 자리는 화면 취향 통(`pref`, 프로젝트마다) — 브라우저 저장소에 두고 계정에도 올라간다
* (`b_page_state`). 설계값이 아니라 [저장] 없이 바로 적는다.
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import {
computeMapRect,
computeMaxScale,
computeRouteView,
metricToScreen,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
type Vertex = [number, number];
/** 기억해 두는 화면 — 가운데(m) · 1m 당 픽셀 · 회전각(도). */
export interface SavedRouteEditView {
cx: number;
cy: number;
pxPerMeter: number;
rotationDeg: number;
}
/** 적기를 잠깐 모은다 — 휠·팬은 프레임마다 그리므로 매번 적으면 저장소를 두드린다. */
const SAVE_DELAY_MS = 400;
/** 배율 하한 — 지도 조작(`_Input`)과 같은 값. */
const MIN_SCALE = 0.5;
const MAX_SCALE_FALLBACK = 16;
/** 화면 px → 사업지 좌표(m). `metricToScreen`이 선형이므로 두 기준점으로 역산한다. */
export function screenToMetric(meta: VWorldMeta, view: ViewState, px: number, py: number): Vertex {
const [x0, y0] = metricToScreen(meta, view, meta.x_min, meta.y_min);
const [x1, y1] = metricToScreen(
meta,
view,
meta.x_min + meta.width_meters,
meta.y_min + meta.height_meters,
);
const sx = (x1 - x0) / (meta.width_meters || 1);
const sy = (y1 - y0) / (meta.height_meters || 1);
return [meta.x_min + (px - x0) / (sx || 1), meta.y_min + (py - y0) / (sy || 1)];
}
/** 화면 1m 당 픽셀 — `metricToScreen` 이 선형이라 100m 떨어진 두 점으로 잰다
* (1m 로 재면 반올림 오차가 그대로 비율에 실린다). */
export function viewPxPerMeter(meta: VWorldMeta, view: ViewState): number {
const [x0] = metricToScreen(meta, view, meta.x_min, meta.y_min);
const [x1] = metricToScreen(meta, view, meta.x_min + 100, meta.y_min);
return Math.abs(x1 - x0) / 100;
}
export function readSavedView(projectId: string): SavedRouteEditView | null {
const saved = readState<Partial<SavedRouteEditView>>("routeedit-view", projectId);
if (!saved) return null;
const { cx, cy, pxPerMeter, rotationDeg } = saved;
if (![cx, cy, pxPerMeter, rotationDeg].every((value) => Number.isFinite(value))) return null;
if (!((pxPerMeter as number) > 0)) return null;
return saved as SavedRouteEditView;
}
/** 기억한 화면을 지금 창 크기에 맞춰 배율·오프셋으로 되돌린다. */
export function restoreSavedView(
meta: VWorldMeta,
view: ViewState,
saved: SavedRouteEditView,
): Pick<ViewState, "scale" | "offsetX" | "offsetY"> {
const mapRect = computeMapRect(meta, view.width, view.height);
const base: ViewState = { ...view, mapRect, scale: 1, offsetX: 0, offsetY: 0 };
const basePx = viewPxPerMeter(meta, base) || 1;
const maxScale = computeMaxScale(meta, mapRect.width, view.width, MAX_SCALE_FALLBACK);
const scale = Math.min(maxScale, Math.max(MIN_SCALE, saved.pxPerMeter / basePx));
// screen = 가운데 + (배율1 자리 − 가운데)·배율 + 오프셋 — 가운데 자리가 한가운데 오게 푼다.
const [bx, by] = metricToScreen(meta, base, saved.cx, saved.cy);
return {
scale,
offsetX: -(bx - view.width / 2) * scale,
offsetY: -(by - view.height / 2) * scale,
};
}
/** 지금 화면을 기억할 꼴로 — 캔버스 한가운데 자리(회전축이라 돌려도 같다)·배율·회전각. */
export function captureView(
meta: VWorldMeta,
view: ViewState,
rotationRad: number,
): SavedRouteEditView {
const [cx, cy] = screenToMetric(meta, view, view.width / 2, view.height / 2);
const rotationDeg = (rotationRad * 180) / Math.PI;
return { cx, cy, pxPerMeter: viewPxPerMeter(meta, view), rotationDeg };
}
/** 창을 열 때의 화면 — 보던 화면이 있으면 그대로, 없으면 노선 전체에 맞춘다(회전 0). */
export function openingView(
projectId: string,
meta: VWorldMeta,
view: ViewState,
nodes: ReadonlyArray<Vertex>,
): { view: ViewState; rotationRad: number } {
const saved = readSavedView(projectId);
if (saved) {
const rotationRad = (saved.rotationDeg * Math.PI) / 180;
return { view: { ...view, ...restoreSavedView(meta, view, saved) }, rotationRad };
}
const xs = nodes.map((vertex) => vertex[0]);
const ys = nodes.map((vertex) => vertex[1]);
const bounds = {
x_min: Math.min(...xs),
x_max: Math.max(...xs),
y_min: Math.min(...ys),
y_max: Math.max(...ys),
};
return {
view: { ...view, ...computeRouteView(meta, bounds, view.width, view.height) },
rotationRad: 0,
};
}
/** 캔버스 버퍼를 감싼 칸 크기(고해상도 배율 포함)에 맞춘다. 칸의 CSS 크기를 돌려준다. */
export function fitCanvas(
canvas: HTMLCanvasElement,
context: CanvasRenderingContext2D,
): { width: number; height: number } {
const wrap = canvas.parentElement!;
const ratio = window.devicePixelRatio || 1;
const width = wrap.clientWidth;
const height = wrap.clientHeight;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
context.setTransform(ratio, 0, 0, ratio, 0, 0);
return { width, height };
}
/** 보던 화면을 잠깐 모아 적는 꾼. `touch` 는 그릴 때마다 불러도 된다. */
export function createViewMemory(
projectId: string,
capture: () => SavedRouteEditView | null,
): { touch: () => void; flush: () => void } {
let timer: ReturnType<typeof setTimeout> | null = null;
let last = "";
const flush = (): void => {
if (timer) clearTimeout(timer);
timer = null;
const now = capture();
if (!now) return;
const text = JSON.stringify(now);
if (text === last) return;
last = text;
writeState("routeedit-view", now, projectId);
};
return {
touch() {
if (timer) clearTimeout(timer);
timer = setTimeout(flush, SAVE_DELAY_MS);
},
flush,
};
}