- 첫 점을 찍을 때 그 노선 땅 높이를 2m 간격(최대 3000점)으로 한 번에 받아 둠 — 커서 이동 중 서버 부르지 않음 - 둘째 점을 누르면 전처럼 두 점만 다시 물어 고정 · 묻는 동안 「지반고 읽는 중」 - 노선을 고치면 높이 표를 다시 받음 - 검증 시나리오 ui_checks/48-1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
350 lines
14 KiB
TypeScript
350 lines
14 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_RouteEdit_Measure.ts
|
|
* 노선 위 **두 점 사이 구간 재기** — 길이와 종단기울기(계획서 0-9 ⑤).
|
|
*
|
|
* Shift+클릭으로 a·b 를 찍는다. 직선·곡선을 가리지 않는다 — 그려지는 폴리라인 위라면 어디든
|
|
* 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다. 계획노선(실선)과 예상노선
|
|
* (점선) 둘 다 잰다 — 첫 점을 찍은 노선을 기억해 둘째 점(과 커서 미리보기)은 그 노선에만
|
|
* 붙는다 — 두 노선이 겹쳐 다른 노선이 위에 있어도 무시한다. 길이 = 두 점 누가거리의 차.
|
|
*
|
|
* 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는
|
|
* 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를
|
|
* 끄는 동안에는 한 번도 부르지 않는다.
|
|
*
|
|
* 첫 점을 찍으면 그 노선 땅 높이를 일정 간격으로 **한 번에** 받아 둔다(계획서 48-1). 커서를
|
|
* 움직이는 동안 길이 · 지반고 · 기울기는 이 표에서 보간해 바로 보이고 서버는 안 부른다.
|
|
* 둘째 점을 누르면 전처럼 두 점만 다시 물어 그 값으로 고정한다.
|
|
* ========================================================================== */
|
|
|
|
import { fetchRouteElevations } from "./B05_Profile_Api_Replan";
|
|
import { routePointAtScreen, type RoutePointHit } from "./B05_Profile_UI_RouteEdit_Input";
|
|
import { stationLabel as formatStation } from "../common_util/common_util_station";
|
|
|
|
type Vertex = [number, number];
|
|
|
|
/** 잰 노선 — 계획노선(실선) · 예상노선(점선). */
|
|
export type MeasureLine = "planned" | "expected";
|
|
|
|
/** 구간 재기로 노선을 짚었다고 볼 거리(px). */
|
|
const MEASURE_HIT_PX = 14;
|
|
/** 커서 미리보기용 땅 높이 표 간격(m). */
|
|
const PROFILE_STEP_M = 2;
|
|
/** 한 번에 묻는 점 수 상한 — 긴 노선은 간격을 넓힌다. */
|
|
const PROFILE_MAX_POINTS = 3000;
|
|
|
|
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;
|
|
/** 커서 미리보기 값이 바뀌었다 — 작은 창 글만 다시 적는다(그리기는 포인터 몫). */
|
|
onHint?: () => void;
|
|
}
|
|
|
|
/** 첫 점 노선의 땅 높이 표 — 누가거리 `chainages[i]` 자리의 지반고 `z[i]`. */
|
|
interface LineProfile {
|
|
/** 표를 만든 노선 — 편집으로 노선이 바뀌면 배열이 새로 생겨 낡은 표를 알아챈다. */
|
|
vertices: Vertex[];
|
|
stepM: number;
|
|
chainages: number[];
|
|
/** 받는 중이면 null. */
|
|
z: Array<number | null> | null;
|
|
failed: boolean;
|
|
}
|
|
|
|
/** 노선 전체 길이(m). */
|
|
function lineLength(vertices: Vertex[]): number {
|
|
let total = 0;
|
|
for (let index = 0; index < vertices.length - 1; index += 1) {
|
|
total += Math.hypot(
|
|
vertices[index + 1][0] - vertices[index][0],
|
|
vertices[index + 1][1] - vertices[index][1],
|
|
);
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/** 노선을 따라 `stepM` 마다 한 점(끝점 포함) — 점과 그 누가거리. */
|
|
function sampleAlong(vertices: Vertex[], stepM: number): { points: Vertex[]; chainages: number[] } {
|
|
const points: Vertex[] = [];
|
|
const chainages: number[] = [];
|
|
let travelled = 0;
|
|
let next = 0;
|
|
for (let index = 0; index < vertices.length - 1; index += 1) {
|
|
const [ax, ay] = vertices[index];
|
|
const [bx, by] = vertices[index + 1];
|
|
const segmentM = Math.hypot(bx - ax, by - ay);
|
|
while (segmentM > 0 && next <= travelled + segmentM) {
|
|
const t = (next - travelled) / segmentM;
|
|
points.push([ax + (bx - ax) * t, ay + (by - ay) * t]);
|
|
chainages.push(next);
|
|
next += stepM;
|
|
}
|
|
travelled += segmentM;
|
|
}
|
|
if (points.length && travelled - chainages[chainages.length - 1] > 1e-6) {
|
|
points.push(vertices[vertices.length - 1]);
|
|
chainages.push(travelled);
|
|
}
|
|
return { points, chainages };
|
|
}
|
|
|
|
/** 표에서 누가거리 자리의 지반고를 선형 보간 — 양옆 중 하나라도 모르면 null. */
|
|
function profileZ(profile: LineProfile, chainageM: number): number | null {
|
|
const { chainages, z } = profile;
|
|
if (!z || chainages.length === 0) return null;
|
|
if (chainages.length === 1) return z[0];
|
|
const index = Math.max(0, Math.min(chainages.length - 2, Math.floor(chainageM / profile.stepM)));
|
|
const [c0, c1] = [chainages[index], chainages[index + 1]];
|
|
const [z0, z1] = [z[index], z[index + 1]];
|
|
if (z0 === null || z1 === null) return null;
|
|
const t = c1 > c0 ? Math.max(0, Math.min(1, (chainageM - c0) / (c1 - c0))) : 0;
|
|
return z0 + (z1 - z0) * t;
|
|
}
|
|
|
|
export interface MeasureMark {
|
|
point: Vertex;
|
|
/** 그 노선 시점에서 따라간 거리(m) — 그리기가 **이 값으로** 구간을 자른다(계획서 0-9 ㉕). */
|
|
chainageM: number;
|
|
line: MeasureLine;
|
|
}
|
|
|
|
export interface MeasureTool {
|
|
/** 찍힌 자리(0~2개) — 그리기가 쓴다. */
|
|
marks: () => MeasureMark[];
|
|
/** 잰 값 한 줄. 찍은 것이 없으면 빈 문자열. */
|
|
hint: () => string;
|
|
/** 재고 있나 — 작은 창을 띄울지 정하는 값. */
|
|
active: () => boolean;
|
|
/** 첫 점만 찍혔을 때 커서 자리 미리보기(첫 점 노선 위). 바뀌었으면 true — 다시 그린다. */
|
|
preview: (px: number, py: number) => 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[] = [];
|
|
/** 첫 점만 찍혔을 때 커서가 가리키는 둘째 점 후보. */
|
|
let hover: MeasureMark | null = null;
|
|
/** 첫 점 노선의 땅 높이 표 — 커서 미리보기 값의 바탕. */
|
|
let profile: LineProfile | null = null;
|
|
/** 두 점을 찍고 그 자리 지반고를 묻는 중 — 답이 오기 전에 「못 읽음」으로 보이지 않게. */
|
|
let fixing = false;
|
|
|
|
/** 첫 점 노선 땅 높이를 한 번에 받아 둔다. 같은 노선 표가 살아 있으면 다시 안 묻는다. */
|
|
const loadProfile = (line: MeasureLine): void => {
|
|
const vertices = params.lines()[line];
|
|
if (profile && profile.vertices === vertices) return;
|
|
const stepM = Math.max(PROFILE_STEP_M, lineLength(vertices) / PROFILE_MAX_POINTS);
|
|
const { points, chainages } = sampleAlong(vertices, stepM);
|
|
const asked: LineProfile = { vertices, stepM, chainages, z: null, failed: !points.length };
|
|
profile = asked;
|
|
if (!points.length) return;
|
|
fetchRouteElevations(params.projectId, points)
|
|
.then((heights) => {
|
|
asked.z = chainages.map((_, index) => heights[index] ?? null);
|
|
})
|
|
.catch(() => {
|
|
asked.failed = true;
|
|
})
|
|
.finally(() => {
|
|
if (params.isClosed() || profile !== asked) return; // 그 사이 다른 노선을 짚었으면 버린다.
|
|
params.onHint?.();
|
|
});
|
|
};
|
|
|
|
/** 구간 한 줄 — 두 점(누가거리 · 지반고)에서 길이 · 지반고 · 종단기울기. */
|
|
const spanText = (
|
|
name: string,
|
|
a: { chainageM: number; z: number | null },
|
|
b: { chainageM: number; z: number | null },
|
|
missing: string,
|
|
): string => {
|
|
const span = Math.abs(b.chainageM - a.chainageM);
|
|
const head =
|
|
`${name}구간 ${formatStation(a.chainageM, params.stationIntervalM)} → ` +
|
|
`${formatStation(b.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`;
|
|
if (a.z === null || b.z === null || span <= 1e-6) return `${head} · ${missing}`;
|
|
// 기울기는 **노선을 따라간 길이** 기준이다 — 직선거리로 나누면 곡선부에서 과대평가된다.
|
|
const rise = b.z - a.z;
|
|
return (
|
|
`${head} · 지반고 ${a.z.toFixed(1)} → ${b.z.toFixed(1)}m` +
|
|
` · 종단기울기 ${((rise / span) * 100).toFixed(1)}%`
|
|
);
|
|
};
|
|
|
|
/** 커서 미리보기 한 줄 — 받아 둔 높이 표에서 보간한 값(누르면 두 점만 다시 물어 고정). */
|
|
const previewText = (first: MeasurePoint, at: MeasureMark): string => {
|
|
const table =
|
|
profile !== null && profile.vertices === params.lines()[first.line] ? profile : null;
|
|
const zOf = (chainageM: number): number | null => (table ? profileZ(table, chainageM) : null);
|
|
const missing = !table
|
|
? "지반고 읽는 중…"
|
|
: table.failed
|
|
? "지반고를 못 읽어 기울기는 못 냅니다."
|
|
: table.z
|
|
? "지표면 밖이라 기울기는 못 냅니다."
|
|
: "지반고 읽는 중…";
|
|
const text = spanText(
|
|
LINE_NAME[first.line],
|
|
{ chainageM: first.chainageM, z: zOf(first.chainageM) },
|
|
{ chainageM: at.chainageM, z: zOf(at.chainageM) },
|
|
missing,
|
|
);
|
|
return `${text} · 누르면 고정`;
|
|
};
|
|
|
|
const hint = (): string => {
|
|
if (picked.length === 0) return "";
|
|
const first = picked[0];
|
|
if (picked.length === 1) {
|
|
if (hover) return previewText(first, hover);
|
|
return `${LINE_NAME[first.line]}구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
|
|
}
|
|
return spanText(
|
|
LINE_NAME[first.line],
|
|
first,
|
|
picked[1],
|
|
fixing ? "지반고 읽는 중…" : "지반고를 못 읽어 기울기는 못 냅니다.",
|
|
);
|
|
};
|
|
|
|
/** 클릭에 가장 가까운 노선 위 점. 첫 점이 찍혀 있으면 **그 노선만** 본다(겹친 자리에서 튀지 않게). */
|
|
const hitAt = (px: number, py: number): (RoutePointHit & { line: MeasureLine }) | null => {
|
|
const lines = params.lines();
|
|
const candidates: MeasureLine[] =
|
|
picked.length === 1 ? [picked[0].line] : ["planned", "expected"];
|
|
const hits = candidates
|
|
.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 best = 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,
|
|
})),
|
|
...(picked.length === 1 && hover ? [hover] : []),
|
|
],
|
|
hint,
|
|
active: () => picked.length > 0,
|
|
preview(px, py) {
|
|
const next = picked.length === 1 ? hitAt(px, py) : null;
|
|
if (!next && !hover) return false;
|
|
hover = next;
|
|
// 노선을 고친 뒤면 표가 낡았다 — 다시 받는다.
|
|
if (next) loadProfile(picked[0].line);
|
|
params.onHint?.();
|
|
return true;
|
|
},
|
|
clear() {
|
|
hover = null;
|
|
profile = null;
|
|
if (picked.length === 0) return;
|
|
picked = [];
|
|
params.onChange();
|
|
},
|
|
async pick(px, py) {
|
|
const hit = hitAt(px, py);
|
|
hover = null;
|
|
if (!hit) {
|
|
picked = []; // 노선을 빗나가면 재던 것을 접는다.
|
|
params.onChange();
|
|
return;
|
|
}
|
|
picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
|
|
fixing = picked.length === 2;
|
|
params.onChange();
|
|
if (picked.length < 2) {
|
|
loadProfile(hit.line);
|
|
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` 가 그렇게 말한다. */
|
|
if (picked !== asked) return;
|
|
}
|
|
fixing = false;
|
|
params.onChange();
|
|
},
|
|
};
|
|
}
|