Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts
T
eomsangdonandClaude Opus 5.5 f72252a5ca Merge remote-tracking branch 'origin/dev' into sub_desktop_1 — 계획노선 편집 창 dmain(기울기 라벨 · 1m 등고선 · 보던 화면) 과 합침
재기 배선은 dmain 의 bindMeasureControls 를 취하고, 두 노선 재기(lines) · 횡단 반폭 선은 살림.

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

191 lines
7.9 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_Measure.ts
* 노선 위 **두 점 사이 구간 재기** — 길이와 종단기울기(계획서 0-9 ⑤).
*
* Shift+클릭으로 a·b 를 찍는다. 직선·곡선을 가리지 않는다 — 그려지는 폴리라인 위라면 어디든
* 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다. 계획노선(실선)과 예상노선
* (점선) 둘 다 잰다 — 두 점은 한 노선 위에 있어야 하고, 다른 노선을 짚으면 새로 시작한다.
*
* 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는
* 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를
* 끄는 동안에는 한 번도 부르지 않는다.
* ========================================================================== */
import { fetchRouteElevations } from "./B05_Profile_Api_Replan";
import { routePointAtScreen, type RoutePointHit } from "./B05_Profile_UI_RouteEdit_Input";
import { formatStation } from "./B05_Profile_Util_Station";
type Vertex = [number, number];
/** 잰 노선 — 계획노선(실선) · 예상노선(점선). */
export type MeasureLine = "planned" | "expected";
/** 구간 재기로 노선을 짚었다고 볼 거리(px). */
const MEASURE_HIT_PX = 14;
interface MeasurePoint extends RoutePointHit {
line: MeasureLine;
/** 그 자리의 지반고(m). 아직 못 물었거나 지표면 밖이면 null. */
z: number | null;
}
export interface MeasureToolParams {
projectId: string;
/** 규칙 측점 간격(m) — 측점 표기에 쓴다. */
stationIntervalM: number;
/** 지금 그려지는 두 노선(원호 포함). 편집으로 바뀌므로 함수로 받는다. */
lines: () => Record<MeasureLine, Vertex[]>;
toScreen: (vertex: Vertex) => [number, number];
/** 창이 닫혔나 — 늦게 온 응답을 죽은 화면에 적지 않으려고. */
isClosed: () => boolean;
/** 상태가 바뀌었다 — 호출부가 상태줄을 다시 적고 다시 그린다. */
onChange: () => void;
}
export interface MeasureMark {
point: Vertex;
/** 그 노선 시점에서 따라간 거리(m) — 그리기가 **이 값으로** 구간을 자른다(계획서 0-9 ㉕). */
chainageM: number;
line: MeasureLine;
}
export interface MeasureTool {
/** 찍힌 자리(0~2개) — 그리기가 쓴다. */
marks: () => MeasureMark[];
/** 잰 값 한 줄. 찍은 것이 없으면 빈 문자열. */
hint: () => string;
/** 재고 있나 — 작은 창을 띄울지 정하는 값. */
active: () => boolean;
/** 한 번 찍기. 두 점이 차면 지반고를 한 번만 물어 온다. */
pick: (px: number, py: number) => Promise<void>;
/** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 0-9 ㉔). */
clear: () => void;
}
const LINE_NAME: Record<MeasureLine, string> = { planned: "", expected: "예상노선 " };
export interface MeasureControlsParams {
measure: MeasureTool;
/** 잰 값 작은 창·글·[거리 재기] 단추·작은 창 닫기 단추. */
box: HTMLElement;
text: HTMLElement;
button: HTMLButtonElement;
closeButton: HTMLElement;
/** 잰 창이 떴다 — 곡선 패널과 같이 뜨지 않게 호출부가 고른 꺾임점을 푼다(㉔). */
onShown: () => void;
redraw: () => void;
}
/** 재기 단추·작은 창 배선. 창 맞추기(`sync`)와 재기 모드인가(`mode`)를 돌려준다. */
export function bindMeasureControls(params: MeasureControlsParams): {
sync: () => void;
mode: () => boolean;
} {
const { measure, box, text, button } = params;
/** 재기 모드 — 켜면 그냥 눌러도 재진다(Shift 는 지름길로 남긴다, ㉓). */
let mode = false;
/** 재고 있으면 작은 창을 띄우고, 아니면 닫는다. **곡선 패널과 같이 뜨지 않는다**(㉔). */
const sync = (): void => {
const on = measure.active();
box.hidden = !on;
text.textContent = measure.hint();
if (on) params.onShown();
};
button.addEventListener("click", () => {
mode = !mode;
button.classList.toggle("is-active", mode);
if (!mode) measure.clear();
});
params.closeButton.addEventListener("click", () => {
measure.clear(); // 닫으면 잰 것이 지워진다(㉔).
mode = false;
button.classList.remove("is-active");
sync();
params.redraw();
});
return { sync, mode: () => mode };
}
export function createMeasureTool(params: MeasureToolParams): MeasureTool {
/** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */
let picked: MeasurePoint[] = [];
const hint = (): string => {
if (picked.length === 0) return "";
const first = picked[0];
const name = LINE_NAME[first.line];
if (picked.length === 1) {
return `${name}구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
}
const second = picked[1];
const span = Math.abs(second.chainageM - first.chainageM);
const head =
`${name}구간 ${formatStation(first.chainageM, params.stationIntervalM)} → ` +
`${formatStation(second.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`;
if (first.z === null || second.z === null || span <= 1e-6) {
return `${head} · 지반고를 못 읽어 기울기는 못 냅니다.`;
}
// 기울기는 **노선을 따라간 길이** 기준이다 — 직선거리로 나누면 곡선부에서 과대평가된다.
const rise = second.z - first.z;
return (
`${head} · 지반고 ${first.z.toFixed(1)} → ${second.z.toFixed(1)}m` +
` · 종단기울기 ${((rise / span) * 100).toFixed(1)}%`
);
};
/** 클릭에 가장 가까운 노선 위 점. 재던 노선이 잡히면 그쪽이 먼저다(겹친 자리에서 튀지 않게). */
const hitAt = (px: number, py: number): (RoutePointHit & { line: MeasureLine }) | null => {
const lines = params.lines();
const hits = (["planned", "expected"] as const)
.map((line) => {
const hit = routePointAtScreen(lines[line], params.toScreen, px, py, MEASURE_HIT_PX);
if (!hit) return null;
const [sx, sy] = params.toScreen(hit.point);
return { ...hit, line, distance: Math.hypot(sx - px, sy - py) };
})
.filter((hit): hit is NonNullable<typeof hit> => hit !== null);
if (!hits.length) return null;
const keep = picked.length === 1 ? hits.find((hit) => hit.line === picked[0].line) : undefined;
const best = keep ?? hits.reduce((a, b) => (b.distance < a.distance ? b : a));
return { point: best.point, chainageM: best.chainageM, line: best.line };
};
return {
marks: () =>
picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM, line: entry.line })),
hint,
active: () => picked.length > 0,
clear() {
if (picked.length === 0) return;
picked = [];
params.onChange();
},
async pick(px, py) {
const hit = hitAt(px, py);
if (!hit) {
picked = []; // 노선을 빗나가면 재던 것을 접는다.
params.onChange();
return;
}
const fresh = picked.length >= 2 || (picked.length === 1 && picked[0].line !== hit.line);
picked = fresh ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
params.onChange();
if (picked.length < 2) return;
const asked = picked;
try {
const heights = await fetchRouteElevations(
params.projectId,
asked.map((entry) => entry.point),
);
if (params.isClosed() || picked !== asked) return; // 그 사이 다시 찍었으면 버린다.
asked.forEach((entry, index) => {
entry.z = heights[index] ?? null;
});
} catch {
/* 지반고를 못 읽으면 길이만 낸다 — `hint` 가 그렇게 말한다. */
}
params.onChange();
},
};
}