- 측점 사이 종단기울기(지반고 %) 라벨 — 측점 간격이 화면 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
169 lines
6.5 KiB
TypeScript
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,
|
|
};
|
|
}
|