- common_util_route_follow.ts followPlusRows(땅 위 자리 → 새 노선 정수 m · 같은 m 하나) · followPlusStations 가 그것을 씀 - Plus_Stations.follow_plus_stations = run_calc 겉(옮긴 수 · 쓰기 그대로) · follow_structures 끌림 걷음 - 등록부 node:facility.plus_follow · NODE_IDS · 시험 test_472_4_follow_node(옛 코드 빨강) · 368_2 · 459 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
352 lines
16 KiB
TypeScript
352 lines
16 KiB
TypeScript
/* =============================================================================
|
|
* common_util/common_util_route_follow.ts
|
|
* 노선이 바뀔 때 관 · 구조물이 땅 위 자리(좌표)를 따라감 — 누가거리 다시 매김(344-2).
|
|
*
|
|
* 자리 정본 = 좌표(x · y) · 누가거리 = 그 자리를 지금 노선에서 읽은 값(정수 m · 234-1).
|
|
* 짝:
|
|
* - `projectPipePoints` ↔ 파이썬 `common_util_drainage_pipes.project_pipe_points`(xy 투영 + 정수 m ·
|
|
* 구간 끝 같은 만큼 밂 · 누가거리 순).
|
|
* - `followStructures` ↔ 파이썬 `common_util_route_follow.follow_structures`(+ 0 ~ 연장 자름 ·
|
|
* 노선과 거리 · `STRUCTURE_ROUTE_DRIFT_MAX_M` 넘으면 「노선에서 멀어짐」 · 버리지 않음).
|
|
* - `projectOnPolyline` = shapely `LineString.project`(가장 가까운 첫 선분) · `distance`.
|
|
* - `placeOnLine` 좌표 낡음 판정 ↔ 파이썬 `fill_structure_coordinates`(좌표가 그 누가거리를 낳으면 살림 ·
|
|
* 아니면 누가거리의 노선 위 자리 · 410) · 거울 시험 = resources/tester/test_410_follow_xy.py.
|
|
* - `placeOnLine` 의 좌표 없는 관 · `rebasePipeDraft` 는 TS 만 — 좌표 없는 세션 관 초안(정본 PUT 꼴)을 따라가기
|
|
* 전 자리로 · 노선을 저장한 [저장]이 서버가 따라 옮긴 관 위에 초안을 얹음(349-0u).
|
|
* - `rebaseStructureDraft` 도 TS 만 — 구조물 초안을 서버가 따라 옮긴 정본 위에(459 · 관 짝).
|
|
* - `followPlusRows` = 추가 측점 따라감 한 벌 — 서버도(Node 입구 `facility.plus_follow` · 472-4 ①) ·
|
|
* `followPlusStations` = 노선 초안 동안(옛 선 위 자리에서).
|
|
* - `routeOutside` ↔ 파이썬 `route_outside`(459 · 노선 밖 = 측점 계획 · 알약에서 뺌 · 좌표 그대로) — 따라감이
|
|
* `outside: true` 를 붙임(파생값 · 저장 때 뺌 · 서버는 측점 세울 때 다시 셈).
|
|
* 거울 시험 = resources/tester/test_344_2_route_pairs.py.
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
gridStations,
|
|
SAME_STATION_M,
|
|
snapExistingStation,
|
|
snapStructureStation,
|
|
} from "./common_util_structure_station";
|
|
|
|
/** 「노선에서 멀어짐」 거리(m) — 파이썬 `config_system.STRUCTURE_ROUTE_DRIFT_MAX_M` 짝(거울 시험이 대조). */
|
|
export const STRUCTURE_ROUTE_DRIFT_MAX_M = 20;
|
|
|
|
/** 노선 밖 판정(m) — 투영이 시점 앞 · 종점 뒤로 이만큼 넘음(459 · 파이썬 `ROUTE_OUTSIDE_M` · 458 주인 측점 상한과 같은 값). */
|
|
export const ROUTE_OUTSIDE_M = 0.51;
|
|
|
|
export interface XY {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
/** 노선 위 자리 — 누가거리 · 노선과의 거리. 노선이 없으면(점 둘 미만 · 길이 0) null. */
|
|
export function projectOnPolyline(
|
|
line: ReadonlyArray<XY>,
|
|
point: XY,
|
|
): { chainage_m: number; distance_m: number; length_m: number } | null {
|
|
let walked = 0;
|
|
let best: { chainage_m: number; distance_m: number } | null = null;
|
|
for (let index = 1; index < line.length; index += 1) {
|
|
const a = line[index - 1];
|
|
const b = line[index];
|
|
const dx = b.x - a.x;
|
|
const dy = b.y - a.y;
|
|
const span = Math.hypot(dx, dy);
|
|
if (span > 0) {
|
|
const t = Math.min(1, Math.max(0, ((point.x - a.x) * dx + (point.y - a.y) * dy) / span ** 2));
|
|
const distance = Math.hypot(a.x + dx * t - point.x, a.y + dy * t - point.y);
|
|
if (best === null || distance < best.distance_m)
|
|
best = { chainage_m: walked + span * t, distance_m: distance };
|
|
}
|
|
walked += span;
|
|
}
|
|
return best && walked > 0 ? { ...best, length_m: walked } : null;
|
|
}
|
|
|
|
/** 노선 밖인가(459) — 가장 가까운 자리가 시점 · 종점이고 그 끝 선분 연장으로 `tolM` 넘게 나감(옆으로 먼 것은 아님).
|
|
* 파이썬 `route_outside` 짝. */
|
|
export function routeOutside(
|
|
line: ReadonlyArray<XY>,
|
|
point: XY,
|
|
tolM: number = ROUTE_OUTSIDE_M,
|
|
): boolean {
|
|
const hit = projectOnPolyline(line, point);
|
|
if (!hit || (hit.chainage_m > 0 && hit.chainage_m < hit.length_m)) return false;
|
|
const [a, b] =
|
|
hit.chainage_m <= 0 ? [line[0], line[1]] : [line[line.length - 1], line[line.length - 2]];
|
|
const span = Math.hypot(b.x - a.x, b.y - a.y);
|
|
if (!(span > 0)) return false;
|
|
return -((point.x - a.x) * (b.x - a.x) + (point.y - a.y) * (b.y - a.y)) / span > tolM;
|
|
}
|
|
|
|
/** 노선 밖 표시를 다시 붙인 사본 — 좌표가 있고 노선 밖이면 `outside: true` · 아니면 칸 뺌(파생값 · 정본에 안 씀). */
|
|
function markOutside<T extends { x?: number | null; y?: number | null }>(
|
|
item: T,
|
|
line: ReadonlyArray<XY>,
|
|
): T {
|
|
const { outside: _was, ...rest } = item as T & { outside?: boolean };
|
|
return item.x != null && item.y != null && routeOutside(line, { x: item.x, y: item.y })
|
|
? ({ ...rest, outside: true } as unknown as T)
|
|
: (rest as T);
|
|
}
|
|
|
|
/** 좌표가 그 누가거리를 낳는가 — `line` 에서 따라감(같은 선 = 제자리)이 누가거리를 안 옮김(410 · 파이썬
|
|
* `fill_structure_coordinates` 같은 규칙). 아니면 좌표가 낡음(마커 · 방향키 · 폼으로 누가거리만 옮김). */
|
|
const holdsStation = (
|
|
item: FollowingStructure,
|
|
line: ReadonlyArray<XY>,
|
|
intervalM: number | null,
|
|
): boolean => {
|
|
const [moved] = followStructures([item], line, Infinity, intervalM);
|
|
return (
|
|
moved.drift_m === null || Math.abs(moved.item.chainage_m! - item.chainage_m!) <= SAME_STATION_M
|
|
);
|
|
};
|
|
|
|
/** 관 · 구조물에 자리를 붙인 사본 — 좌표가 그 누가거리를 낳으면 그대로(같은 객체 · 410) · 좌표 없음(세션 관
|
|
* 초안 = 정본 PUT 꼴 · 좌표 뺌)이면 같은 id · 같은 누가거리인 `saved`(정본 · 앞 따라감 · 좌표 있음)의 좌표(안
|
|
* 옮긴 것 = 서버 따라감과 같은 자리) · 그 밖(옮김 · 좌표 낡음)은 그 누가거리의 `line` 위 자리(연장 밖은 끝 선분
|
|
* 위로 자름 = 옮긴 자리가 새 기준). 누가거리가 없으면 그대로. `intervalM` = site 맞춤 간격(`followStructures`). */
|
|
export function placeOnLine<S extends FollowingStructure & { id?: string | null }>(
|
|
items: ReadonlyArray<S>,
|
|
line: ReadonlyArray<XY>,
|
|
saved: ReadonlyArray<FollowingStructure & { id?: string | null }> = [],
|
|
intervalM: number | null = null,
|
|
): S[] {
|
|
const byId = new Map(saved.map((item) => [item.id, item]));
|
|
return items.map((item) => {
|
|
if (item.chainage_m == null) return item;
|
|
const placed = item.x != null && item.y != null;
|
|
if (placed && holdsStation(item, line, intervalM)) return item;
|
|
const was = !placed && item.id ? byId.get(item.id) : undefined;
|
|
if (
|
|
was?.x != null &&
|
|
was.y != null &&
|
|
was.chainage_m != null &&
|
|
Math.abs(was.chainage_m - item.chainage_m) <= SAME_STATION_M
|
|
)
|
|
return { ...item, x: was.x, y: was.y };
|
|
let walked = 0;
|
|
for (let index = 1; index < line.length; index += 1) {
|
|
const a = line[index - 1];
|
|
const b = line[index];
|
|
const span = Math.hypot(b.x - a.x, b.y - a.y);
|
|
if (span > 0 && (walked + span >= item.chainage_m || index === line.length - 1)) {
|
|
const t = Math.min(1, Math.max(0, (item.chainage_m - walked) / span));
|
|
return { ...item, x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
|
}
|
|
walked += span;
|
|
}
|
|
return item;
|
|
});
|
|
}
|
|
|
|
/** 노선을 저장한 [저장] — 세션 관 초안(목록 한 벌)을 서버가 따라 옮긴 관(`after`) 위에 얹은 목록(누가거리 순).
|
|
* 초안 관이 노선 저장 전 정본(`before`) 자리 그대로면(따라감 전 값) · 따라가기만 했으면(땅 위 자리 = 정본 x · y ·
|
|
* 누가거리만 화면 계산 · 428) 서버 자리로(구간 끝 · 좌표도) · 사용자가 옮긴 · 새로 놓은 관은 그대로 · 서버가 새로
|
|
* 놓은 관(옛 정본에 없던 id)은 붙임 · 짝은 id. */
|
|
export function rebasePipeDraft<P extends FollowingPoint & { id?: string | null }>(
|
|
draft: ReadonlyArray<P>,
|
|
before: ReadonlyArray<FollowingPoint & { id?: string | null }>,
|
|
after: ReadonlyArray<P>,
|
|
): P[] {
|
|
const old = new Map(before.map((pipe) => [pipe.id, pipe]));
|
|
const moved = new Map(after.map((pipe) => [pipe.id, pipe]));
|
|
const mine = new Set(draft.map((pipe) => pipe.id));
|
|
const kept = draft.map((pipe) => {
|
|
const was = pipe.id ? old.get(pipe.id) : undefined;
|
|
const now = pipe.id ? moved.get(pipe.id) : undefined;
|
|
const followed =
|
|
pipe.x != null &&
|
|
pipe.y != null &&
|
|
was?.x != null &&
|
|
was.y != null &&
|
|
Math.hypot(pipe.x - was.x, pipe.y - was.y) <= FOLLOW_SAME_XY_M;
|
|
if (!was || !now || (!followed && Math.abs(pipe.chainage_m - was.chainage_m) > SAME_STATION_M))
|
|
return pipe;
|
|
const shift = now.chainage_m - pipe.chainage_m;
|
|
return {
|
|
...pipe,
|
|
chainage_m: now.chainage_m,
|
|
...(pipe.start_m != null ? { start_m: pipe.start_m + shift } : {}),
|
|
...(pipe.end_m != null ? { end_m: pipe.end_m + shift } : {}),
|
|
...(now.x != null && now.y != null ? { x: now.x, y: now.y } : {}),
|
|
};
|
|
});
|
|
const added = after.filter((pipe) => pipe.id && !old.has(pipe.id) && !mine.has(pipe.id));
|
|
return [...kept, ...added].sort((a, b) => a.chainage_m - b.chainage_m);
|
|
}
|
|
|
|
type StructureLike = FollowingStructure & { structure_id?: string | null };
|
|
|
|
/** 자리 셋(기준 · 구간 끝)이 같은가 — `shift` 만큼 민 자리로 견줌(구간 길이 = 시설 치수). */
|
|
const samePositions = (a: FollowingStructure, b: FollowingStructure, shift = 0): boolean =>
|
|
(["chainage_m", "start_m", "end_m"] as const).every(
|
|
(key) =>
|
|
(a[key] == null) === (b[key] == null) &&
|
|
Math.abs((a[key] ?? 0) + shift - (b[key] ?? 0)) <= SAME_STATION_M,
|
|
);
|
|
|
|
/** 노선을 저장한 [저장] — 세션 구조물 초안을 서버가 따라 옮긴 정본(`after`) 위에 얹은 목록(459 · 관
|
|
* `rebasePipeDraft` 짝). 따라가기만 한 것(자리 = 노선 저장 전 정본 `before` 그대로 · 또는 땅 위 자리가 같고
|
|
* 기준 누가거리가 서버 따라감과 같음)은 서버 자리(기준 · 구간 끝 · 좌표) · 사용자가 옮긴 것(마커 · 폼 =
|
|
* 누가거리만 옮겨 좌표 낡음 포함) · 새로 놓은 것은 초안 그대로 · 옵션 · 메모는 늘 초안 · 짝은 `structure_id`. */
|
|
export function rebaseStructureDraft<S extends StructureLike>(
|
|
draft: ReadonlyArray<S>,
|
|
before: ReadonlyArray<StructureLike>,
|
|
after: ReadonlyArray<StructureLike>,
|
|
): S[] {
|
|
const old = new Map(before.map((item) => [item.structure_id, item]));
|
|
const moved = new Map(after.map((item) => [item.structure_id, item]));
|
|
return draft.map((item) => {
|
|
const was = item.structure_id ? old.get(item.structure_id) : undefined;
|
|
const now = item.structure_id ? moved.get(item.structure_id) : undefined;
|
|
if (!was || !now) return item;
|
|
const followed =
|
|
item.x != null &&
|
|
item.y != null &&
|
|
was.x != null &&
|
|
was.y != null &&
|
|
Math.hypot(item.x - was.x, item.y - was.y) <= FOLLOW_SAME_XY_M &&
|
|
samePositions(was, item, (item.chainage_m ?? 0) - (was.chainage_m ?? 0)) &&
|
|
Math.abs((item.chainage_m ?? 0) - (now.chainage_m ?? 0)) <= SAME_STATION_M;
|
|
if (!followed && !samePositions(item, was)) return item;
|
|
return {
|
|
...item,
|
|
chainage_m: now.chainage_m ?? item.chainage_m,
|
|
start_m: now.start_m ?? null,
|
|
end_m: now.end_m ?? null,
|
|
x: now.x ?? item.x,
|
|
y: now.y ?? item.y,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** 땅 위 자리(x · y)가 있는 추가 측점이 새 선(`to`)을 따라감 — 정수 m · 누가거리 순 · 같은 m 하나(앞엣것) ·
|
|
* 좌표 그대로. 서버 정본(`plus_stations.json`)도 이 셈(Node 입구 `facility.plus_follow` · 472-4 ①). */
|
|
export function followPlusRows<R extends FollowingStructure>(
|
|
rows: ReadonlyArray<R>,
|
|
to: ReadonlyArray<XY>,
|
|
): R[] {
|
|
const seen = new Set<number>();
|
|
return followStructures(rows, to)
|
|
.map(({ item }) => item)
|
|
.sort((a, b) => a.chainage_m! - b.chainage_m!)
|
|
.filter((item) => !seen.has(item.chainage_m!) && seen.add(item.chainage_m!));
|
|
}
|
|
|
|
/** 추가 측점이 노선을 따라감(459) — 옛 선(`from`) 위 누가거리의 땅 위 자리를 새 선(`to`)에 투영한 정수 m
|
|
* (같은 m 하나 · 오름차순 · 노선 초안 동안 = 좌표가 아직 옛 선 위). */
|
|
export function followPlusStations(
|
|
list: ReadonlyArray<number>,
|
|
from: ReadonlyArray<XY>,
|
|
to: ReadonlyArray<XY>,
|
|
): number[] {
|
|
const placed = placeOnLine(
|
|
list.map((chainage_m) => ({ chainage_m })),
|
|
from,
|
|
);
|
|
return followPlusRows(placed, to).map((item) => item.chainage_m!);
|
|
}
|
|
|
|
/** 따라가기만 한 관 = 정본과 같은 땅 위 자리(m) — 서버 좌표가 mm 3자리라 그 반올림 폭. */
|
|
const FOLLOW_SAME_XY_M = 0.001;
|
|
|
|
export interface FollowingPoint {
|
|
chainage_m: number;
|
|
x?: number | null;
|
|
y?: number | null;
|
|
start_m?: number | null;
|
|
end_m?: number | null;
|
|
}
|
|
|
|
/** 저장 좌표를 새 노선에 투영해 누가거리를 다시 매긴 사본(누가거리 순) — 관 지점. */
|
|
export function projectPipePoints<P extends FollowingPoint>(
|
|
points: ReadonlyArray<P>,
|
|
line: ReadonlyArray<XY>,
|
|
): P[] {
|
|
const length = line.reduce(
|
|
(sum, point, index) =>
|
|
index ? sum + Math.hypot(point.x - line[index - 1].x, point.y - line[index - 1].y) : 0,
|
|
0,
|
|
);
|
|
if (!(length > 0)) return [...points]; // 파이썬도 노선이 없으면 그대로(차례도)
|
|
return points
|
|
.map((point) => {
|
|
if (point.x == null || point.y == null) return { ...point };
|
|
const hit = projectOnPolyline(line, { x: point.x, y: point.y })!;
|
|
const moved = snapStructureStation(hit.chainage_m, [hit.length_m]);
|
|
const shift = moved - point.chainage_m;
|
|
return markOutside(
|
|
{
|
|
...point,
|
|
chainage_m: moved,
|
|
...(point.start_m != null ? { start_m: point.start_m + shift } : {}),
|
|
...(point.end_m != null ? { end_m: point.end_m + shift } : {}),
|
|
},
|
|
line,
|
|
);
|
|
})
|
|
.sort((a, b) => a.chainage_m - b.chainage_m);
|
|
}
|
|
|
|
export interface FollowingStructure {
|
|
placement?: string | null;
|
|
chainage_m?: number | null;
|
|
start_m?: number | null;
|
|
end_m?: number | null;
|
|
x?: number | null;
|
|
y?: number | null;
|
|
}
|
|
|
|
/** 구조물이 좌표를 따라감 — 사본 · 노선과 거리(좌표 없으면 null) · 멀어짐(차례는 그대로) ·
|
|
* `intervalM` 주면 site 꼴(사토장 · 토취장) = 새 노선 일반 측점 중 가장 가까운 곳(380-3 · 파이썬은 서버 간격). */
|
|
export function followStructures<S extends FollowingStructure>(
|
|
items: ReadonlyArray<S>,
|
|
line: ReadonlyArray<XY>,
|
|
maxDriftM: number = STRUCTURE_ROUTE_DRIFT_MAX_M,
|
|
intervalM: number | null = null,
|
|
): Array<{ item: S; drift_m: number | null; far: boolean }> {
|
|
return items.map((item) => {
|
|
const hit =
|
|
item.x != null && item.y != null ? projectOnPolyline(line, { x: item.x, y: item.y }) : null;
|
|
if (hit === null || item.chainage_m == null)
|
|
return { item: { ...item }, drift_m: null, far: false };
|
|
const target =
|
|
intervalM && item.placement === "site"
|
|
? snapExistingStation(hit.chainage_m, gridStations(hit.length_m, intervalM))
|
|
: snapStructureStation(hit.chainage_m, [hit.length_m]);
|
|
const shift = target - item.chainage_m;
|
|
const clamp = (value: number): number => Math.min(Math.max(value + shift, 0), hit.length_m);
|
|
return {
|
|
item: markOutside(
|
|
{
|
|
...item,
|
|
chainage_m: clamp(item.chainage_m),
|
|
...(item.start_m != null ? { start_m: clamp(item.start_m) } : {}),
|
|
...(item.end_m != null ? { end_m: clamp(item.end_m) } : {}),
|
|
},
|
|
line,
|
|
),
|
|
drift_m: hit.distance_m,
|
|
far: hit.distance_m > maxDriftM,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** 묶음 follow 입력 꼴 — 목록이면 `placeOnLine`(앞 노선 꼭짓점 `[x, y]`) · 아니면(null 등) 그대로. */
|
|
export const placedOnLine = (
|
|
list: unknown,
|
|
line: ReadonlyArray<[number, number]>,
|
|
saved: FollowingStructure[],
|
|
intervalM: number | null = null,
|
|
): unknown =>
|
|
Array.isArray(list)
|
|
? placeOnLine(
|
|
list as FollowingStructure[],
|
|
line.map(([x, y]) => ({ x, y })),
|
|
saved,
|
|
intervalM,
|
|
)
|
|
: list;
|