Files
Aislo/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts
T
eomsangdonandClaude Opus 5.5 cdcdd5f597 feat(562-3 · 563-1): D03 손 긋기 화면 — 도구 여섯 · 그리기 · 거리재기 · 구역 연도 · 횡단 판 · 예상 코리도 걷음
- UI_Map: 바탕(위성 · 등고선) + 겹(그리기 · 거리재기) · 도구줄 부품(되돌리기 · 다시하기 · 3D · 좌 · 우 회전 · 거리재기) · B05 createMapRotation · 왼쪽 끌기 이동 끔(가운데 단추 · 휠 방향 = 설정)
- 새 UI_Draw: 시점에서 점 찍어 직선 · 미리보기 길이 · 기울기 · 제약 칸(자유 · 길이 고정 · 기울기 고정 · 둘 다 · 20 m · 9 %) · 종점 붙기 · 갈래 · 구역 마크 · Enter · Esc · Backspace
- 새 UI_Measure: 두 점 길이 · 기울기 · 높이 차(지형 heightAt)
- logic/Plan: 선 · 갈래 · 조각 꼴 다루기로 다시 씀(옛 갈래 넷 · 이은 누가 걷음)
- UI_Page · Api: 지형 범위 카카오 지도 미리보기 → [이 범위 받기] · [범위 밖 더 받기] · GET · PUT plan/network · [초기화] · 조각 연도 고름 · 캐시 판 2
- UI_3D: 지형 + 그은 선(선 고름) · 예상 코리도 · 체크 칸 걷음
- 지움: UI_Cross · UI_Edit · logic/Expect · logic/Edit · test_544_1(예상 횡단 · 예상 코리도 셈이 화면에서 걷혀 지킬 대상 없음)
- 시험: 530_3 = 손 긋기 꼴 다루기 + 서버 derive 대조로 다시 씀 · 553_4 D03 부품 단언 새 화면으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:16:08 +09:00

237 lines
8.9 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_Plan.ts
* D 예정노선 2단계 손 긋기 꼴(PLAN 562-3) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기.
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
* [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음.
* ========================================================================== */
import { lineLength } from "./D03_PlanAnalysis_Draw";
export type Vertex = [number, number];
/** 그은 선 하나 — 첫 선 = 본선(D02 시점에서) · 둘째부터 = 갈래(`branch` = 붙은 선 · 꺾임점 차례). */
export interface PlanLine {
id: string;
vertices: Vertex[];
branch: { line: string; vertex: number } | null;
}
/** 조각 = 선(id) 위 [from_m, to_m] 구역 · 연도(구역 마크 = 조각 경계 · id = 업체 지정이 붙는 열쇠라 나눠도 앞 조각이 지님). */
export interface PlanPiece {
id: string;
line: string;
from_m: number;
to_m: number;
year: number;
}
export interface PlanNetwork {
lines: PlanLine[];
pieces: PlanPiece[];
}
/** 마크가 끝 · 다른 마크와 이보다 가까우면 안 놓음(m). */
export const MARK_GAP_M = 1;
const round3 = (value: number): number => Math.round(value * 1000) / 1000;
/** 새 조각 id — `p<n>`(있는 것 중 가장 큰 수 + 1). */
function nextPieceId(pieces: ReadonlyArray<PlanPiece>): string {
const used = pieces.map((piece) => Number(/^p(\d+)$/.exec(piece.id)?.[1] ?? 0));
return `p${Math.max(0, ...used) + 1}`;
}
/** 빈 선 — 본선 시점만. */
export function emptyNetwork(start: Vertex): PlanNetwork {
return { lines: [{ id: "main", vertices: [start], branch: null }], pieces: [] };
}
/** 선마다 조각이 0 ~ 길이를 빈틈없이 덮게 맞춤 — 경계 = 조각 시작(0 · 끝 근처 빼고) · 연도 = 그 자리 조각 ·
* 조각 없는 선 = 한 조각(`year`). 길이 0 선은 조각 없음. */
export function normalizePieces(network: PlanNetwork, year: number): PlanNetwork {
const pieces: PlanPiece[] = [];
for (const line of network.lines) {
const length = round3(lineLength(line.vertices));
if (!(length > 0)) continue;
const own = network.pieces
.filter((piece) => piece.line === line.id)
.sort((a, b) => a.from_m - b.from_m);
const starts = own
.filter((piece) => piece.from_m > MARK_GAP_M / 2 && piece.from_m < length - MARK_GAP_M / 2)
.map((piece) => ({ id: piece.id, from: round3(piece.from_m), year: piece.year }));
const head = own.find((piece) => piece.from_m <= MARK_GAP_M / 2) ?? { id: "", year };
const cuts = [{ id: head.id, from: 0, year: head.year }, ...starts];
cuts.forEach((cut, i) =>
pieces.push({
// id 빈 칸 · 연도 없음(서버 마크만 있던 구역) = 새 id · 처음 연도
id: cut.id || nextPieceId([...network.pieces, ...pieces]),
line: line.id,
from_m: cut.from,
to_m: i + 1 < cuts.length ? cuts[i + 1].from : length,
year: Number.isFinite(cut.year) ? cut.year : year,
}),
);
}
return { ...network, pieces };
}
/** 선 위 `atM` 에 마크 — 그 자리 조각을 둘로(같은 연도) · 끝 · 경계와 가까우면 그대로. */
export function splitPiece(network: PlanNetwork, line: string, atM: number): PlanNetwork {
const index = network.pieces.findIndex(
(piece) => piece.line === line && atM > piece.from_m && atM < piece.to_m,
);
const piece = network.pieces[index];
if (!piece || atM - piece.from_m < MARK_GAP_M || piece.to_m - atM < MARK_GAP_M) return network;
const at = round3(atM);
const pieces = [...network.pieces];
pieces.splice(
index,
1,
{ ...piece, to_m: at },
{ ...piece, id: nextPieceId(pieces), from_m: at },
);
return { ...network, pieces };
}
/** `atM` 둘레 `toleranceM` 안 마크를 지움 — 두 조각을 합침(앞 조각 연도). 없으면 그대로. */
export function removeMark(
network: PlanNetwork,
line: string,
atM: number,
toleranceM: number,
): PlanNetwork {
const index = network.pieces.findIndex(
(piece) =>
piece.line === line && piece.from_m > 0 && Math.abs(piece.from_m - atM) <= toleranceM,
);
if (index < 1 || network.pieces[index - 1].line !== line) return network;
const pieces = [...network.pieces];
const before = pieces[index - 1];
pieces.splice(index - 1, 2, { ...before, to_m: pieces[index].to_m });
return { ...network, pieces };
}
/** 조각 연도 바꿈. */
export function setPieceYear(network: PlanNetwork, index: number, year: number): PlanNetwork {
return {
...network,
pieces: network.pieces.map((piece, i) => (i === index ? { ...piece, year } : piece)),
};
}
/** `line` 째 선의 `segment` 번째 구간 안에 꺾임점을 끼움 — 그 선에 붙은 갈래 차례를 한 칸 밈.
* 선 위 점이라 누가 · 조각은 그대로. */
export function insertVertex(
network: PlanNetwork,
line: number,
segment: number,
point: Vertex,
): PlanNetwork {
const id = network.lines[line].id;
return {
...network,
lines: network.lines.map((entry, i) => {
if (i === line) {
const vertices = [...entry.vertices];
vertices.splice(segment + 1, 0, point);
return { ...entry, vertices };
}
const branch = entry.branch;
return branch && branch.line === id && branch.vertex > segment
? { ...entry, branch: { ...branch, vertex: branch.vertex + 1 } }
: entry;
}),
};
}
/** 선 끝에 점을 더함. */
export function appendVertex(network: PlanNetwork, line: number, point: Vertex): PlanNetwork {
return {
...network,
lines: network.lines.map((entry, i) =>
i === line ? { ...entry, vertices: [...entry.vertices, point] } : entry,
),
};
}
/** 선 끝 점을 뺌 — 첫 점(본선 시점 · 갈래 붙은 점)은 못 뺌 · 갈래가 붙은 점도 못 뺌(까닭 글). */
export function dropLastVertex(network: PlanNetwork, line: number): PlanNetwork | string {
const entry = network.lines[line];
if (!entry || entry.vertices.length < 2) return "더 뺄 점이 없습니다.";
const last = entry.vertices.length - 1;
if (
network.lines.some((other) => other.branch?.line === entry.id && other.branch.vertex === last)
)
return "갈래가 붙은 점은 뺄 수 없습니다 — 갈래를 먼저 지우세요.";
return {
...network,
lines: network.lines.map((other, i) =>
i === line ? { ...other, vertices: other.vertices.slice(0, last) } : other,
),
};
}
/** 마지막 선(그리던 갈래)을 지움 — 그 선 조각도. 갈래가 붙어 있으면 그대로. */
export function dropLastLine(network: PlanNetwork): PlanNetwork {
const index = network.lines.length - 1;
const id = network.lines[index]?.id;
if (index < 1 || network.lines.some((line) => line.branch?.line === id)) return network;
return {
lines: network.lines.slice(0, index),
pieces: network.pieces.filter((piece) => piece.line !== id),
};
}
/** 새 갈래 — `line` 째 선의 `vertex` 꺾임점에서 시작. */
export function addBranch(network: PlanNetwork, line: number, vertex: number): PlanNetwork {
const parent = network.lines[line];
const from = parent.vertices[vertex];
const used = new Set(network.lines.map((entry) => entry.id));
let n = network.lines.length;
while (used.has(`branch-${n}`)) n += 1;
return {
...network,
lines: [
...network.lines,
{ id: `branch-${n}`, vertices: [[from[0], from[1]]], branch: { line: parent.id, vertex } },
],
};
}
/** 서버 꼴(선 · 마크 · 조각 · 조각 없는 구역 있음) → 화면 꼴(조각이 선을 빈틈없이 덮음). */
export function fromServer(
lines: PlanLine[],
marks: ReadonlyArray<{ line: string; at_m: number }>,
pieces: ReadonlyArray<PlanPiece>,
year: number,
): PlanNetwork {
const cuts: PlanPiece[] = marks
.filter(
(mark) =>
!pieces.some(
(piece) => piece.line === mark.line && Math.abs(piece.from_m - mark.at_m) < 0.01,
),
)
.map((mark) => ({ id: "", line: mark.line, from_m: mark.at_m, to_m: mark.at_m, year: NaN }));
return normalizePieces({ lines, pieces: [...pieces, ...cuts] }, year);
}
/** 서버 꼴 마크 — 조각 경계(선마다 0 빼고 시작 자리). */
export function marksOf(network: PlanNetwork): Array<{ line: string; at_m: number }> {
return network.pieces
.filter((piece) => piece.from_m > 0)
.map((piece) => ({ line: piece.line, at_m: piece.from_m }));
}
/** 본선이 종점에 닿았나(1 mm). */
export function reachedEnd(network: PlanNetwork, end: Vertex): boolean {
const main = network.lines[0]?.vertices ?? [];
const last = main[main.length - 1];
return !!last && Math.hypot(last[0] - end[0], last[1] - end[1]) < 1e-3;
}
/** 선 이름 — 본선 · 갈래 1 · 갈래 2 …(선 차례). */
export function lineName(index: number): string {
return index === 0 ? "본선" : `갈래 ${index}`;
}