feat(D03): 예정노선 그리기 — 시점 · 종점 여럿 · 들어올 때 자동 받기 · [긋기] 양끝 · 부분 수정 · 기울기 % · ° 미리보기 · 방향 바꾸기 · 선 고르기 · 지우기 · 연도 (PLAN 579-3 · 580-1~3 · 요구 440 · 441)
- 579-3: D02 시점 · 종점 목록 받음 · 점 그림 · 맞춤 · 등고선 범위 · 「범위 밖」 판정에 시점 · 종점 · 들어올 때 받은 범위가 못 덮으면 바로 받기(진행 막대 · 직전 실패면 안 함) - 선 이름 = 갈래 없는 선 「본선」(둘 이상 「본선 N」) · 갈래 「갈래 N」 — TS lineNames ↔ D04 line_names 짝 · 거울 시험 - 580-1: [긋기] 하나 — 시점 · 종점(끝낼 때 뒤집어 No.0 = 반대쪽) · 선 꼬리 · 선 머리 · 선 위(갈래)에서 시작 · 머리가 다른 선 꼬리에 닿으면 한 선 · 꺾임점이면 갈래 · 꺾임점 끌기 · 더하기 · 지우기(마크는 구간 비율) - 580-2: 미리보기 글 = 거리 + 가상 종단 기울기 % · ° · 기울기 고정 칸 옆 ° 환산 · 고른 선 [방향 바꾸기] - 580-3: 지도에서 선 · 조각 고름(좌측 줄 밝힘) · Delete · [선 지움] · 선 통째 연도 · 긋는 해(긋는 중 바꾸면 다음 점부터) · [연도 구분] 뒤 연도 고름 바로 · 마크 끌기 - 새 파일: D03_PlanAnalysis_UI_Edit.ts(고르기 · 고치기) · D03_PlanAnalysis_UI_DrawPaint.ts(그림 · 집기 — UI_Draw 700 줄 넘어 나눔) - UI_Map = 겹 꼴에 release · pointerup · 끌기 중 포인터 잡기 · [?] 안내 줄(그리기 본체 draw() 는 sub2 몫이라 손 안 댐) - 시험: 530-3 새 꼴 함수 · 서버 derive 대조 · 선 이름 거울 / 553-4 그림 자리 옮김 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,11 +19,11 @@ export interface BasePoint {
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** D02 정본 — 전체 시점 · 종점(작업 좌표계 m + 경위도). */
|
||||
/** D02 정본 — 시점 목록 · 종점 목록(작업 좌표계 m + 경위도 · 579-1). */
|
||||
export interface PlanBaseView {
|
||||
epsg: number | null;
|
||||
start: BasePoint | null;
|
||||
end: BasePoint | null;
|
||||
starts: BasePoint[];
|
||||
ends: BasePoint[];
|
||||
}
|
||||
|
||||
export interface PlanJob {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Draw.ts
|
||||
* D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 요구서 389 3장 8~10) — 시점에서 점을 찍어 직선으로 이음.
|
||||
* D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 580 · 요구서 389 3장 8~10 · 440 3~7) — 점을 찍어 직선으로 이음.
|
||||
*
|
||||
* 본선 = D02 시점에서 · D02 종점 둘레(SNAP_PX)에 오면 종점에 붙고 그 점에서 끝.
|
||||
* 미리보기 = 마지막 점 → 마우스 길이(m) · 원지반 기울기(%, + 오르막) — 제약 칸 넷:
|
||||
* 자유 · 길이 고정 · 기울기 고정(마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중 마우스에 가까운 것) ·
|
||||
* 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈) · 미리보기 값은 + 오르막 / − 내리막.
|
||||
* 갈래 = 기존 선 꺾임점(선 위면 꺾임점 끼움)을 눌러 시작 · 구역 마크 = 선 위 누름(오른쪽 = 지움).
|
||||
* [긋기] 하나 — 첫 누름 자리로 시작: 시점 = 새 본선 · 종점 = 새 본선(끝낼 때 뒤집어 종점이 선 끝 ·
|
||||
* 측점 No.0 = 반대쪽) · 선 꼬리 = 이어 긋기 · 갈래 없는 선 머리 = 뒤집어 이어 긋고 끝낼 때 되돌림 ·
|
||||
* 선 위 · 꺾임점 = 갈래. 시점 · 종점 · 다른 선 둘레(SNAP_PX)에 오면 거기 붙고 그 점에서 끝 —
|
||||
* 머리가 다른 선 꼬리에 닿으면 한 선으로 잇고 · 꺾임점에 닿으면 그 점의 갈래.
|
||||
* 미리보기 = 마지막 점 → 마우스 길이(m) · 가상 종단 기울기(원지반 % · ° · + 오르막) — 제약 칸 넷:
|
||||
* 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중
|
||||
* 마우스에 가까운 것) · 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈).
|
||||
* 긋는 해 = 새 조각 처음 해 · 긋는 중 해를 바꾸면 다음 점부터 그 해(지금 끝 자리에 연도 구분 마크).
|
||||
* 연도 구분 = 선 위 누름(마크 · 뒤 조각 고름 → 페이지가 연도 고름을 엶) · 오른쪽 = 지움.
|
||||
* 고르기 · 고치기(쉴 때) = `D03_PlanAnalysis_UI_Edit`.
|
||||
* 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기(설정 창 단축키 표 D03 줄).
|
||||
* 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`) · 꼴 다루기 = `logic/D03_PlanAnalysis_Plan` ·
|
||||
* 값을 바꾸면 `commit(다음 꼴)` 한 번 = 되돌리기 한 걸음(스택 · 캐시 = 페이지).
|
||||
@@ -16,50 +21,49 @@ import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { createSegment } from "@ui/ui_template_segment";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
||||
import {
|
||||
compassCandidates,
|
||||
compassPoint,
|
||||
distanceAlong,
|
||||
fixedLengthPoint,
|
||||
fixedSlopePoint,
|
||||
groundSlopePct,
|
||||
lineLength,
|
||||
pointAtDistance,
|
||||
segmentLength,
|
||||
stationChainages,
|
||||
} from "./logic/D03_PlanAnalysis_Draw";
|
||||
import {
|
||||
addBranch,
|
||||
appendVertex,
|
||||
dropLastLine,
|
||||
attachHead,
|
||||
dropLastVertex,
|
||||
insertVertex,
|
||||
reachedEnd,
|
||||
newLine,
|
||||
normalizePieces,
|
||||
removeLine,
|
||||
removeMark,
|
||||
reverseLine,
|
||||
samePoint,
|
||||
setPieceYear,
|
||||
splitPiece,
|
||||
type PlanNetwork,
|
||||
type Vertex,
|
||||
} from "./logic/D03_PlanAnalysis_Plan";
|
||||
import {
|
||||
planColors,
|
||||
PLAN_STATION_INTERVAL_M,
|
||||
type PlanMapLayer,
|
||||
type ScreenMap,
|
||||
} from "./D03_PlanAnalysis_UI_Map";
|
||||
fmt,
|
||||
lineAt,
|
||||
paintNetwork,
|
||||
paintPreview,
|
||||
screenGap,
|
||||
slopeDeg,
|
||||
SNAP_PX,
|
||||
} from "./D03_PlanAnalysis_UI_DrawPaint";
|
||||
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||||
|
||||
/** 종점 · 꺾임점에 붙는 반경(px). */
|
||||
const SNAP_PX = 12;
|
||||
/** 선 위 누름으로 볼 반경(px). */
|
||||
const LINE_HIT_PX = 8;
|
||||
/** 구간 길이 라벨을 보일 화면 길이(px). */
|
||||
const LABEL_MIN_PX = 70;
|
||||
/** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */
|
||||
export const DRAW_DEFAULT_LENGTH_M = 20;
|
||||
export const DRAW_DEFAULT_SLOPE_PCT = 9;
|
||||
|
||||
export type DrawConstraint = "free" | "length" | "slope" | "both";
|
||||
export type DrawMode = "idle" | "draw" | "branch" | "mark";
|
||||
export type DrawMode = "idle" | "draw" | "mark";
|
||||
|
||||
export interface DrawPreview {
|
||||
point: Vertex;
|
||||
@@ -69,15 +73,21 @@ export interface DrawPreview {
|
||||
note: string | null;
|
||||
blocked: boolean;
|
||||
snapped: boolean;
|
||||
/** 다른 선 위(꺾임점 아님)에 붙음 — 점을 찍을 때 그 선에 꺾임점을 끼움. */
|
||||
onLine: { line: number; segment: number } | null;
|
||||
}
|
||||
|
||||
export interface PlanDrawParams {
|
||||
base: () => { start: Vertex; end: Vertex };
|
||||
base: () => { starts: Vertex[]; ends: Vertex[] };
|
||||
network: () => PlanNetwork;
|
||||
heightAt: () => (x: number, y: number) => number;
|
||||
commit: (next: PlanNetwork) => void;
|
||||
/** 긋는 해 — 새 조각 처음 해 · 긋는 중 바꾸면 다음 점부터. */
|
||||
year: () => number;
|
||||
/** 모드 · 그리는 선이 바뀜 — 페이지가 단추 · 안내를 다시. */
|
||||
onMode: () => void;
|
||||
/** 연도 구분 마크를 놓음 — 뒤 조각(id) 고름 · 연도 고름. */
|
||||
onMark: (piece: string) => void;
|
||||
}
|
||||
|
||||
export interface PlanDraw {
|
||||
@@ -91,21 +101,18 @@ export interface PlanDraw {
|
||||
key: (event: KeyboardEvent) => boolean;
|
||||
/** 되돌리기 뒤 — 그리던 선이 사라졌으면 그리기를 멈춤. */
|
||||
sync: () => void;
|
||||
/** 검증 창구 — 지금 미리보기 · 제약. */
|
||||
/** 검증 창구 — 지금 미리보기 · 제약 · 그리는 선(차례 · 끝에서 긋는 중). */
|
||||
preview: () => DrawPreview | null;
|
||||
stroke: () => { line: number; flip: boolean } | null;
|
||||
constraint: () => { kind: DrawConstraint; lengthM: number; slopePct: number };
|
||||
setConstraint: (kind: DrawConstraint, lengthM?: number, slopePct?: number) => void;
|
||||
}
|
||||
|
||||
const fmt = (value: number): string => value.toFixed(1);
|
||||
const signed = (value: number): string => `${value >= 0 ? "+" : ""}${value.toFixed(1)}`;
|
||||
const near = (a: [number, number], b: [number, number], px: number): boolean =>
|
||||
Math.hypot(a[0] - b[0], a[1] - b[1]) <= px;
|
||||
|
||||
export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
let mode: DrawMode = "idle";
|
||||
let editable = false;
|
||||
let active: number | null = null; // 그리는 선 차례
|
||||
let active: number | null = null; // 그리는 선 차례(첫 누름 전 = null)
|
||||
let flip = false; // 끝에서 긋는 중 — 끝낼 때 뒤집음
|
||||
let strokeStart: PlanNetwork | null = null; // Esc 로 돌아갈 꼴
|
||||
let preview: DrawPreview | null = null;
|
||||
let candidates: Vertex[] = [];
|
||||
@@ -114,27 +121,21 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
|
||||
|
||||
// ── 좌측 칸 ─────────────────────────────────────────────
|
||||
const mainButton = createButton({
|
||||
label: "본선 긋기",
|
||||
const drawButton = createButton({
|
||||
label: "긋기",
|
||||
variant: "filled",
|
||||
size: 28,
|
||||
onClick: () => setMode(mode === "draw" && active === 0 ? "idle" : "draw"),
|
||||
});
|
||||
const branchButton = createButton({
|
||||
label: "갈래",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () => setMode(mode === "branch" ? "idle" : "branch"),
|
||||
onClick: () => setMode(mode === "draw" ? "idle" : "draw"),
|
||||
});
|
||||
const markButton = createButton({
|
||||
label: "구역 마크",
|
||||
label: "연도 구분",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () => setMode(mode === "mark" ? "idle" : "mark"),
|
||||
});
|
||||
const modeRow = document.createElement("div");
|
||||
modeRow.className = "d03-plan__row";
|
||||
modeRow.append(mainButton, branchButton, markButton);
|
||||
modeRow.append(drawButton, markButton);
|
||||
const kindSegment = createSegment({
|
||||
options: [
|
||||
{ value: "free", label: "자유" },
|
||||
@@ -173,14 +174,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
size: 24,
|
||||
onInput: (value) => {
|
||||
const next = Number(value);
|
||||
if (value.trim() !== "" && Number.isFinite(next)) slopePct = next;
|
||||
slopeField.setError(value.trim() !== "" && Number.isFinite(next) ? undefined : "숫자");
|
||||
const ok = value.trim() !== "" && Number.isFinite(next);
|
||||
if (ok) slopePct = next;
|
||||
slopeField.setError(ok ? undefined : "숫자");
|
||||
degText();
|
||||
},
|
||||
});
|
||||
slopeField.input.dataset.role = "draw-slope";
|
||||
const slopeDegText = createText("", "muted");
|
||||
slopeDegText.dataset.role = "draw-slope-deg";
|
||||
const degText = (): void => {
|
||||
slopeDegText.textContent = `≈ ${fmt(slopeDeg(Math.abs(slopePct)))}°`;
|
||||
};
|
||||
degText();
|
||||
const fieldRow = document.createElement("div");
|
||||
fieldRow.className = "d03-plan__row";
|
||||
fieldRow.append(lengthField.root, slopeField.root);
|
||||
fieldRow.append(lengthField.root, slopeField.root, slopeDegText);
|
||||
const hint = createText("", "muted");
|
||||
hint.classList.add("d03-plan__hint");
|
||||
const panel = document.createElement("div");
|
||||
@@ -194,26 +203,21 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
|
||||
function panelState(): void {
|
||||
const network = params.network();
|
||||
const done = reachedEnd(network, params.base().end);
|
||||
mainButton.querySelector(".ui-btn__label")!.textContent =
|
||||
mode === "draw" && active === 0 ? "긋기 끝" : done ? "본선 끝남" : "본선 긋기";
|
||||
mainButton.disabled = !editable || (done && !(mode === "draw" && active === 0));
|
||||
branchButton.disabled = !editable || lineLength(network.lines[0]?.vertices ?? []) <= 0;
|
||||
markButton.disabled = branchButton.disabled;
|
||||
branchButton.setAttribute("aria-pressed", String(mode === "branch"));
|
||||
drawButton.querySelector(".ui-btn__label")!.textContent = mode === "draw" ? "긋기 끝" : "긋기";
|
||||
drawButton.disabled = !editable;
|
||||
markButton.disabled = !editable || !network.lines.some((line) => lineLength(line.vertices) > 0);
|
||||
drawButton.setAttribute("aria-pressed", String(mode === "draw"));
|
||||
markButton.setAttribute("aria-pressed", String(mode === "mark"));
|
||||
panel.dataset.mode = mode;
|
||||
hint.textContent = !editable
|
||||
? "보기만 — 산림청 계정에서 긋습니다."
|
||||
: mode === "draw"
|
||||
? "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기"
|
||||
: mode === "branch"
|
||||
? "갈래를 시작할 선 위 점을 누르세요."
|
||||
: mode === "mark"
|
||||
? "선 위를 눌러 구역을 나눕니다 · 오른쪽 누름 = 마크 지움"
|
||||
: done
|
||||
? "본선이 종점에 닿았습니다 — 갈래 · 구역 마크 · 연도를 정하세요."
|
||||
: "[본선 긋기]로 시점에서 점을 찍어 갑니다.";
|
||||
? active === null
|
||||
? "시점 · 종점 · 선 끝 · 선 위를 눌러 시작합니다."
|
||||
: "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기"
|
||||
: mode === "mark"
|
||||
? "선 위를 눌러 연도를 나눕니다 · 오른쪽 누름 = 마크 지움"
|
||||
: "[긋기]로 시점 · 종점 · 선에서 긋습니다 · 선을 누르면 고릅니다.";
|
||||
}
|
||||
fieldsState();
|
||||
|
||||
@@ -223,20 +227,46 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
return vertices[vertices.length - 1] ?? null;
|
||||
};
|
||||
|
||||
/** 마우스 자리 → 찍을 점 · 값 — 종점 붙기가 제약보다 먼저(본선만). */
|
||||
/** 붙을 자리 — 앞에서 = 종점 · 끝에서 = 시점 · 다른 선 꺾임점 · 다른 선 위(그리는 선 빼고). */
|
||||
function snapAt(
|
||||
px: number,
|
||||
py: number,
|
||||
map: ScreenMap,
|
||||
): { point: Vertex; note: string; onLine: DrawPreview["onLine"] } | null {
|
||||
const { starts, ends } = params.base();
|
||||
const network = params.network();
|
||||
const offers = (flip ? starts : ends).map((point) => ({
|
||||
point,
|
||||
note: flip ? "시점에 붙음" : "종점에 붙음",
|
||||
}));
|
||||
network.lines.forEach((line, i) => {
|
||||
if (i !== active)
|
||||
for (const point of line.vertices) offers.push({ point, note: "선 꺾임점에 붙음" });
|
||||
});
|
||||
const best = offers
|
||||
.map((offer) => ({ ...offer, gap: screenGap(map, offer.point, px, py) }))
|
||||
.filter((offer) => offer.gap <= SNAP_PX)
|
||||
.sort((a, b) => a.gap - b.gap)[0];
|
||||
if (best) return { point: best.point, note: best.note, onLine: null };
|
||||
const hit = lineAt(network, px, py, map, active);
|
||||
return hit
|
||||
? { point: hit.point, note: "선 위에 붙음", onLine: { line: hit.line, segment: hit.segment } }
|
||||
: null;
|
||||
}
|
||||
|
||||
/** 마우스 자리 → 찍을 점 · 값 — 붙기가 제약보다 먼저. */
|
||||
function previewAt(px: number, py: number, map: ScreenMap): DrawPreview | null {
|
||||
if (active === null) return null;
|
||||
const from = lastOf(active);
|
||||
if (!from) return null;
|
||||
const z = params.heightAt();
|
||||
const cursor = map.toMetric(px, py);
|
||||
const end = params.base().end;
|
||||
candidates = kind === "both" ? compassCandidates(from, lengthM, slopePct, z) : [];
|
||||
const snap = snapAt(px, py, map);
|
||||
let point: Vertex | null = cursor;
|
||||
let note: string | null = null;
|
||||
let snapped = false;
|
||||
if (active === 0 && near(map.toScreen(end), [px, py], SNAP_PX)) {
|
||||
[point, note, snapped] = [end, "종점에 붙음", true];
|
||||
if (snap) {
|
||||
[point, note] = [snap.point, snap.note];
|
||||
} else if (kind === "length") {
|
||||
point = fixedLengthPoint(from, cursor, lengthM);
|
||||
} else if (kind === "slope") {
|
||||
@@ -254,56 +284,39 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
slopePct: groundSlopePct(from, at, z),
|
||||
note,
|
||||
blocked: !point || length <= 0,
|
||||
snapped,
|
||||
snapped: !!snap,
|
||||
onLine: snap?.onLine ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 화면 px 둘레 선 위 자리 — 가장 가까운 선 · 누가 · 꺾임점(가까우면) · 구간. */
|
||||
function lineAt(
|
||||
px: number,
|
||||
py: number,
|
||||
map: ScreenMap,
|
||||
): { line: number; atM: number; point: Vertex; vertex: number | null; segment: number } | null {
|
||||
const cursor = map.toMetric(px, py);
|
||||
let best: ReturnType<typeof lineAt> = null;
|
||||
let bestPx = LINE_HIT_PX;
|
||||
params.network().lines.forEach((line, index) => {
|
||||
if (line.vertices.length < 2) return;
|
||||
// 붙는 반경 안 꺾임점 중 가장 가까운 것
|
||||
let vertex = -1;
|
||||
let vertexPx = SNAP_PX;
|
||||
line.vertices.forEach((v, i) => {
|
||||
const [vx, vy] = map.toScreen(v);
|
||||
const d = Math.hypot(vx - px, vy - py);
|
||||
if (d <= vertexPx) [vertex, vertexPx] = [i, d];
|
||||
});
|
||||
const atM =
|
||||
vertex >= 0
|
||||
? lineLength(line.vertices.slice(0, vertex + 1))
|
||||
: distanceAlong(line.vertices, cursor);
|
||||
const point = vertex >= 0 ? line.vertices[vertex] : pointAtDistance(line.vertices, atM);
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
const gap = vertex >= 0 ? 0 : Math.hypot(sx - px, sy - py);
|
||||
if (gap > bestPx || (best && gap === bestPx)) return;
|
||||
// 누가가 든 구간(꺾임점 끼울 자리)
|
||||
let segment = 0;
|
||||
for (let walked = 0; segment < line.vertices.length - 2; segment += 1) {
|
||||
walked += segmentLength(line.vertices[segment], line.vertices[segment + 1]);
|
||||
if (walked >= atM) break;
|
||||
}
|
||||
best = { line: index, atM, point, vertex: vertex >= 0 ? vertex : null, segment };
|
||||
bestPx = gap;
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
// ── 모드 ────────────────────────────────────────────────
|
||||
/** 이번 긋기 마침 — 점 하나뿐인 선 버림 · 끝에서 그은 선은 뒤집고 머리 잇기 · 꼬리가 다른 선 머리면 잇기. */
|
||||
function stop(): void {
|
||||
// 점 하나뿐인 갈래는 버림(본선 시점은 남김)
|
||||
const network = params.network();
|
||||
if (active !== null && active > 0 && (network.lines[active]?.vertices.length ?? 0) < 2)
|
||||
params.commit(dropLastLine(network));
|
||||
let network = params.network();
|
||||
const line = active;
|
||||
const entry = line === null ? null : network.lines[line];
|
||||
if (line !== null && entry) {
|
||||
if (entry.vertices.length < 2) {
|
||||
const dropped = removeLine(network, line);
|
||||
if (typeof dropped !== "string") network = dropped;
|
||||
} else if (flip) {
|
||||
const turned = reverseLine(network, line);
|
||||
if (typeof turned !== "string") network = attachHead(turned, line);
|
||||
} else {
|
||||
const tail = entry.vertices[entry.vertices.length - 1];
|
||||
const next = network.lines.findIndex(
|
||||
(other, i) =>
|
||||
i !== line &&
|
||||
!other.branch &&
|
||||
other.vertices.length > 1 &&
|
||||
samePoint(other.vertices[0], tail),
|
||||
);
|
||||
if (next >= 0) network = attachHead(network, next);
|
||||
}
|
||||
if (network !== params.network()) params.commit(network);
|
||||
}
|
||||
active = null;
|
||||
flip = false;
|
||||
strokeStart = null;
|
||||
preview = null;
|
||||
candidates = [];
|
||||
@@ -312,20 +325,93 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
function setMode(next: DrawMode): void {
|
||||
if (mode === "draw") stop();
|
||||
mode = editable ? next : "idle";
|
||||
if (mode === "draw") {
|
||||
const network = params.network();
|
||||
if (reachedEnd(network, params.base().end)) {
|
||||
showToast("본선이 이미 종점에 닿았습니다 — 갈래는 [갈래]로 긋습니다.", "info");
|
||||
mode = "idle";
|
||||
} else {
|
||||
active = 0;
|
||||
strokeStart = network;
|
||||
}
|
||||
}
|
||||
panelState();
|
||||
params.onMode();
|
||||
}
|
||||
|
||||
/** [긋기] 첫 누름 — 선 끝 이어 긋기 · 종점(뒤집어 긋기) · 시점 · 선 위 갈래. 참 = 시작함. */
|
||||
function begin(px: number, py: number, map: ScreenMap): boolean {
|
||||
const network = params.network();
|
||||
const { starts, ends } = params.base();
|
||||
const gap = (point: Vertex): number => screenGap(map, point, px, py);
|
||||
const nearest = <T extends { gap: number }>(items: T[]): T | undefined =>
|
||||
items.filter((item) => item.gap <= SNAP_PX).sort((a, b) => a.gap - b.gap)[0];
|
||||
const onBase = (list: Vertex[], point: Vertex): boolean =>
|
||||
list.some((p) => samePoint(p, point));
|
||||
strokeStart = network;
|
||||
// 1) 선 끝 — 꼬리(종점에 닿은 꼬리 빼고) · 갈래 없는 선 머리(시점에 놓인 머리 빼고)
|
||||
const tips: Array<{ line: number; head: boolean; gap: number }> = [];
|
||||
network.lines.forEach((line, i) => {
|
||||
const tail = line.vertices[line.vertices.length - 1];
|
||||
const head = line.vertices[0];
|
||||
if (tail && !onBase(ends, tail)) tips.push({ line: i, head: false, gap: gap(tail) });
|
||||
if (line.vertices.length > 1 && !line.branch && !onBase(starts, head))
|
||||
tips.push({ line: i, head: true, gap: gap(head) });
|
||||
});
|
||||
const tip = nearest(tips);
|
||||
if (tip) {
|
||||
if (tip.head) {
|
||||
const turned = reverseLine(network, tip.line);
|
||||
if (typeof turned === "string") {
|
||||
showToast(turned, "info");
|
||||
return false;
|
||||
}
|
||||
params.commit(turned);
|
||||
}
|
||||
[active, flip] = [tip.line, tip.head];
|
||||
return true;
|
||||
}
|
||||
// 2) 시점 · 종점
|
||||
const base = nearest([
|
||||
...starts.map((point) => ({ point, fromEnd: false, gap: gap(point) })),
|
||||
...ends.map((point) => ({ point, fromEnd: true, gap: gap(point) })),
|
||||
]);
|
||||
if (base) {
|
||||
const { point, fromEnd } = base;
|
||||
const owner = fromEnd
|
||||
? -1
|
||||
: network.lines.findIndex((line) => line.vertices.some((v) => samePoint(v, point)));
|
||||
if (owner >= 0) {
|
||||
// 이미 선이 놓인 시점 = 그 점의 갈래
|
||||
const vertex = network.lines[owner].vertices.findIndex((v) => samePoint(v, point));
|
||||
params.commit(addBranch(network, owner, vertex));
|
||||
} else params.commit(newLine(network, point));
|
||||
[active, flip] = [params.network().lines.length - 1, fromEnd];
|
||||
return true;
|
||||
}
|
||||
// 3) 선 위 · 꺾임점 = 갈래
|
||||
const hit = lineAt(network, px, py, map);
|
||||
if (hit) {
|
||||
let next = network;
|
||||
let vertex = hit.vertex;
|
||||
if (vertex === null) {
|
||||
next = insertVertex(next, hit.line, hit.segment, hit.point);
|
||||
vertex = hit.segment + 1;
|
||||
}
|
||||
params.commit(addBranch(next, hit.line, vertex));
|
||||
[active, flip] = [params.network().lines.length - 1, false];
|
||||
return true;
|
||||
}
|
||||
strokeStart = null;
|
||||
showToast("시점 · 종점 · 선 끝 · 선 위를 눌러 시작합니다.", "info");
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 긋는 해가 끝 조각 해와 다르면 `fromM`(이번 점 앞 길이)에서 나눠 새 조각 = 긋는 해. */
|
||||
function yearSplit(network: PlanNetwork, line: number, fromM: number): PlanNetwork {
|
||||
const year = params.year();
|
||||
if (!(fromM > 0)) return network;
|
||||
let next = normalizePieces(network, year);
|
||||
const id = next.lines[line].id;
|
||||
const tail = next.pieces.filter((piece) => piece.line === id).at(-1);
|
||||
if (!tail || tail.year === year) return next;
|
||||
next = splitPiece(next, id, fromM);
|
||||
const index = next.pieces.findIndex(
|
||||
(piece) => piece.line === id && Math.abs(piece.from_m - fromM) < 1e-3,
|
||||
);
|
||||
return index >= 0 ? setPieceYear(next, index, year) : next;
|
||||
}
|
||||
|
||||
function addPoint(): boolean {
|
||||
if (active === null || !preview) return false;
|
||||
if (preview.blocked) {
|
||||
@@ -334,9 +420,14 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
}
|
||||
const line = active;
|
||||
const ended = preview.snapped;
|
||||
params.commit(appendVertex(params.network(), line, preview.point));
|
||||
let next = params.network();
|
||||
const before = Math.round(lineLength(next.lines[line].vertices) * 1000) / 1000;
|
||||
if (preview.onLine)
|
||||
next = insertVertex(next, preview.onLine.line, preview.onLine.segment, preview.point);
|
||||
next = yearSplit(appendVertex(next, line, preview.point), line, before);
|
||||
params.commit(next);
|
||||
if (ended) {
|
||||
showToast("본선이 종점에 닿았습니다.", "success");
|
||||
showToast(`${preview.note ?? "붙음"} — 긋기 끝`, "success");
|
||||
setMode("idle");
|
||||
}
|
||||
return true;
|
||||
@@ -344,95 +435,44 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
|
||||
const layer: PlanMapLayer = {
|
||||
draw(context, map) {
|
||||
const colors = planColors();
|
||||
const { start, end } = params.base();
|
||||
const network = params.network();
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
network.lines.forEach((line, index) => {
|
||||
if (line.vertices.length < 2) return;
|
||||
context.strokeStyle = index === active ? colors.active : colors.line;
|
||||
context.lineWidth = index === 0 ? 3 : 2.5;
|
||||
context.beginPath();
|
||||
line.vertices.forEach((v, i) => {
|
||||
const [sx, sy] = map.toScreen(v);
|
||||
if (i) context.lineTo(sx, sy);
|
||||
else context.moveTo(sx, sy);
|
||||
});
|
||||
context.stroke();
|
||||
if (map.stations)
|
||||
drawStationTicks(
|
||||
paintNetwork(context, map, network, params.base(), active, params.heightAt(), () => {
|
||||
const from = active === null ? null : lastOf(active);
|
||||
if (from && preview)
|
||||
paintPreview(
|
||||
context,
|
||||
line.vertices.map(([x, y]) => ({ x, y })),
|
||||
{
|
||||
intervalM: PLAN_STATION_INTERVAL_M,
|
||||
pxPerMeter: map.pxPerMeter,
|
||||
toScreen: (x, y) => map.toScreen([x, y]),
|
||||
uprightRad: map.uprightRad,
|
||||
chainages: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M),
|
||||
},
|
||||
map,
|
||||
from,
|
||||
preview,
|
||||
kind === "both" ? { lengthM, candidates } : null,
|
||||
);
|
||||
if (map.slopes) segmentLabels(context, map, line.vertices);
|
||||
});
|
||||
// 꺾임점
|
||||
context.fillStyle = "#ffffff";
|
||||
for (const line of network.lines)
|
||||
for (const v of line.vertices.slice(1)) {
|
||||
const [sx, sy] = map.toScreen(v);
|
||||
context.beginPath();
|
||||
context.arc(sx, sy, 3, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
drawPieces(context, map, network);
|
||||
drawPreview(context, map, colors);
|
||||
// 시점(초록) · 종점(빨강)
|
||||
for (const [point, fill] of [
|
||||
[start, "#16a34a"],
|
||||
[end, "#dc2626"],
|
||||
] as const) {
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
context.beginPath();
|
||||
context.arc(sx, sy, 7, 0, Math.PI * 2);
|
||||
context.fillStyle = fill;
|
||||
context.fill();
|
||||
context.lineWidth = 2;
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.stroke();
|
||||
}
|
||||
},
|
||||
press(px, py, map, button) {
|
||||
if (!editable) return false;
|
||||
if (mode === "draw" && button === 0) {
|
||||
if (active === null) {
|
||||
const started = begin(px, py, map);
|
||||
panelState();
|
||||
params.onMode();
|
||||
return started;
|
||||
}
|
||||
preview = previewAt(px, py, map);
|
||||
return addPoint();
|
||||
}
|
||||
if (mode === "branch" && button === 0) {
|
||||
const hit = lineAt(px, py, map);
|
||||
if (!hit) return false;
|
||||
let network = params.network();
|
||||
let vertex = hit.vertex;
|
||||
if (vertex === null) {
|
||||
network = insertVertex(network, hit.line, hit.segment, hit.point);
|
||||
vertex = hit.segment + 1;
|
||||
}
|
||||
strokeStart = params.network();
|
||||
params.commit(addBranch(network, hit.line, vertex));
|
||||
mode = "draw";
|
||||
active = params.network().lines.length - 1;
|
||||
panelState();
|
||||
params.onMode();
|
||||
return true;
|
||||
}
|
||||
if (mode === "mark") {
|
||||
const hit = lineAt(px, py, map);
|
||||
if (!hit) return false;
|
||||
const network = params.network();
|
||||
const hit = lineAt(network, px, py, map);
|
||||
if (!hit) return false;
|
||||
const id = network.lines[hit.line].id;
|
||||
const next =
|
||||
button === 2
|
||||
? removeMark(network, id, hit.atM, SNAP_PX / map.pxPerMeter)
|
||||
: splitPiece(network, id, hit.atM);
|
||||
if (next !== network) params.commit(next);
|
||||
if (next === network) return true;
|
||||
const added = next.pieces.find((piece) => !network.pieces.some((p) => p.id === piece.id));
|
||||
params.commit(next);
|
||||
if (added) params.onMark(added.id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -445,120 +485,6 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
},
|
||||
};
|
||||
|
||||
function segmentLabels(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
vertices: Vertex[],
|
||||
): void {
|
||||
const z = params.heightAt();
|
||||
for (let i = 1; i < vertices.length; i += 1) {
|
||||
const [a, b] = [map.toScreen(vertices[i - 1]), map.toScreen(vertices[i])];
|
||||
if (Math.hypot(b[0] - a[0], b[1] - a[1]) < LABEL_MIN_PX) continue;
|
||||
const slope = groundSlopePct(vertices[i - 1], vertices[i], z);
|
||||
const text = `${fmt(segmentLength(vertices[i - 1], vertices[i]))} m · ${slope === null ? "—" : `${signed(slope)} %`}`;
|
||||
label(context, map, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 - 12, text, "11px");
|
||||
}
|
||||
}
|
||||
|
||||
function drawPieces(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
network: PlanNetwork,
|
||||
): void {
|
||||
const colors = planColors();
|
||||
network.pieces.forEach((piece) => {
|
||||
const vertices = network.lines.find((line) => line.id === piece.line)?.vertices;
|
||||
if (!vertices || vertices.length < 2) return;
|
||||
if (piece.from_m > 0) {
|
||||
const [sx, sy] = map.toScreen(pointAtDistance(vertices, piece.from_m));
|
||||
context.beginPath();
|
||||
context.moveTo(sx, sy - 8);
|
||||
context.lineTo(sx + 6, sy);
|
||||
context.lineTo(sx, sy + 8);
|
||||
context.lineTo(sx - 6, sy);
|
||||
context.closePath();
|
||||
context.fillStyle = colors.active;
|
||||
context.fill();
|
||||
context.lineWidth = 1.5;
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.stroke();
|
||||
}
|
||||
const [mx, my] = map.toScreen(pointAtDistance(vertices, (piece.from_m + piece.to_m) / 2));
|
||||
label(context, map, mx, my + 16, `${piece.year}년`, "bold 12px");
|
||||
});
|
||||
}
|
||||
|
||||
function drawPreview(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
colors: ReturnType<typeof planColors>,
|
||||
): void {
|
||||
if (active === null || !preview) return;
|
||||
const from = lastOf(active);
|
||||
if (!from) return;
|
||||
const [ax, ay] = map.toScreen(from);
|
||||
const [bx, by] = map.toScreen(preview.point);
|
||||
if (kind === "both") {
|
||||
context.strokeStyle = colors.active;
|
||||
context.lineWidth = 1;
|
||||
context.setLineDash([3, 4]);
|
||||
context.beginPath();
|
||||
context.arc(ax, ay, lengthM * map.pxPerMeter, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
context.fillStyle = colors.active;
|
||||
for (const c of candidates) {
|
||||
const [cx, cy] = map.toScreen(c);
|
||||
context.beginPath();
|
||||
context.arc(cx, cy, 3, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
}
|
||||
context.strokeStyle = preview.blocked ? colors.warn : colors.active;
|
||||
context.lineWidth = 2;
|
||||
context.setLineDash([6, 4]);
|
||||
context.beginPath();
|
||||
context.moveTo(ax, ay);
|
||||
context.lineTo(bx, by);
|
||||
context.stroke();
|
||||
context.setLineDash([]);
|
||||
context.beginPath();
|
||||
context.arc(bx, by, 5, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
const value =
|
||||
`${fmt(preview.lengthM)} m · ` +
|
||||
(preview.slopePct === null ? "지형 밖" : `${signed(preview.slopePct)} %`);
|
||||
label(
|
||||
context,
|
||||
map,
|
||||
bx + 14,
|
||||
by - 14,
|
||||
preview.note ? `${value} · ${preview.note}` : value,
|
||||
"12px",
|
||||
"left",
|
||||
);
|
||||
}
|
||||
|
||||
function label(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
font: string,
|
||||
align: CanvasTextAlign = "center",
|
||||
): void {
|
||||
map.upright(context, x, y, () => {
|
||||
context.font = `${font} system-ui, sans-serif`;
|
||||
context.textAlign = align;
|
||||
context.textBaseline = "middle";
|
||||
context.lineWidth = 3;
|
||||
context.strokeStyle = planColors().halo;
|
||||
context.strokeText(text, x, y);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillText(text, x, y);
|
||||
});
|
||||
}
|
||||
|
||||
panelState();
|
||||
|
||||
return {
|
||||
@@ -579,19 +505,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
if (mode !== "idle" && event.key === "Escape") {
|
||||
if (mode === "draw" && strokeStart && strokeStart !== params.network())
|
||||
params.commit(strokeStart);
|
||||
active = null; // 되돌린 꼴이라 stop 의 갈래 버림은 안 함
|
||||
active = null; // 되돌린 꼴이라 stop 의 버림 · 뒤집기는 안 함
|
||||
setMode("idle");
|
||||
return true;
|
||||
}
|
||||
if (mode === "draw" && active !== null && event.key === "Backspace") {
|
||||
const network = params.network();
|
||||
const step =
|
||||
active > 0 && network.lines[active].vertices.length < 2
|
||||
? dropLastLine(network)
|
||||
network.lines[active].vertices.length < 2
|
||||
? removeLine(network, active)
|
||||
: dropLastVertex(network, active);
|
||||
if (typeof step === "string") showToast(step, "info");
|
||||
else if (step !== network) params.commit(step);
|
||||
if (active >= params.network().lines.length) setMode("idle");
|
||||
if (active >= params.network().lines.length) {
|
||||
active = null;
|
||||
setMode("idle");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -599,17 +528,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
sync() {
|
||||
if (active !== null && active >= params.network().lines.length) {
|
||||
active = null;
|
||||
flip = false;
|
||||
mode = "idle";
|
||||
}
|
||||
panelState();
|
||||
},
|
||||
preview: () => preview,
|
||||
stroke: () => (active === null ? null : { line: active, flip }),
|
||||
constraint: () => ({ kind, lengthM, slopePct }),
|
||||
setConstraint(next, length, slope) {
|
||||
kind = next;
|
||||
kindSegment.set(next);
|
||||
if (length !== undefined) [lengthM, lengthField.input.value] = [length, String(length)];
|
||||
if (slope !== undefined) [slopePct, slopeField.input.value] = [slope, String(slope)];
|
||||
if (slope !== undefined) {
|
||||
[slopePct, slopeField.input.value] = [slope, String(slope)];
|
||||
degText();
|
||||
}
|
||||
fieldsState();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_DrawPaint.ts
|
||||
* D03 손 긋기 겹 그림 · 집기(PLAN 562-3 · 580) — `D03_PlanAnalysis_UI_Draw`(긋기) ·
|
||||
* `D03_PlanAnalysis_UI_Edit`(고르기 · 고치기)가 같이 씀. 그림 = 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 ·
|
||||
* 시점 · 종점 · 미리보기(거리 · 가상 종단 기울기 % · °) · 집기 = 화면 px 둘레 선 위 자리.
|
||||
* ========================================================================== */
|
||||
|
||||
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
||||
import {
|
||||
distanceAlong,
|
||||
groundSlopePct,
|
||||
lineLength,
|
||||
pointAtDistance,
|
||||
segmentLength,
|
||||
stationChainages,
|
||||
type HeightAt,
|
||||
} from "./logic/D03_PlanAnalysis_Draw";
|
||||
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import { planColors, PLAN_STATION_INTERVAL_M, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||||
import type { DrawPreview } from "./D03_PlanAnalysis_UI_Draw";
|
||||
|
||||
/** 시점 · 종점 · 꺾임점에 붙는 반경(px). */
|
||||
export const SNAP_PX = 12;
|
||||
/** 선 위 누름으로 볼 반경(px). */
|
||||
export const LINE_HIT_PX = 8;
|
||||
/** 구간 길이 라벨을 보일 화면 길이(px). */
|
||||
const LABEL_MIN_PX = 70;
|
||||
|
||||
export const fmt = (value: number): string => value.toFixed(1);
|
||||
const signed = (value: number): string => `${value >= 0 ? "+" : ""}${value.toFixed(1)}`;
|
||||
/** 기울기 % → 각(°) — 가상 종단 기울기를 두 단위로. */
|
||||
export const slopeDeg = (pct: number): number => (Math.atan(pct / 100) * 180) / Math.PI;
|
||||
const slopeText = (pct: number): string => `${signed(pct)} % (${signed(slopeDeg(pct))}°)`;
|
||||
/** 화면 px 거리(작업 좌표계 점 ↔ 마우스). */
|
||||
export const screenGap = (map: ScreenMap, point: Vertex, px: number, py: number): number => {
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
return Math.hypot(sx - px, sy - py);
|
||||
};
|
||||
|
||||
/** 화면 px 둘레 선 위 자리 — 가장 가까운 선 · 누가 · 꺾임점(가까우면) · 구간 · `skip` 선 빼고. */
|
||||
export function lineAt(
|
||||
network: PlanNetwork,
|
||||
px: number,
|
||||
py: number,
|
||||
map: ScreenMap,
|
||||
skip: number | null = null,
|
||||
): { line: number; atM: number; point: Vertex; vertex: number | null; segment: number } | null {
|
||||
const cursor = map.toMetric(px, py);
|
||||
let best: ReturnType<typeof lineAt> = null;
|
||||
let bestPx = LINE_HIT_PX;
|
||||
network.lines.forEach((line, index) => {
|
||||
if (line.vertices.length < 2 || index === skip) return;
|
||||
// 붙는 반경 안 꺾임점 중 가장 가까운 것
|
||||
let vertex = -1;
|
||||
let vertexPx = SNAP_PX;
|
||||
line.vertices.forEach((v, i) => {
|
||||
const [vx, vy] = map.toScreen(v);
|
||||
const d = Math.hypot(vx - px, vy - py);
|
||||
if (d <= vertexPx) [vertex, vertexPx] = [i, d];
|
||||
});
|
||||
const atM =
|
||||
vertex >= 0
|
||||
? lineLength(line.vertices.slice(0, vertex + 1))
|
||||
: distanceAlong(line.vertices, cursor);
|
||||
const point = vertex >= 0 ? line.vertices[vertex] : pointAtDistance(line.vertices, atM);
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
const gap = vertex >= 0 ? 0 : Math.hypot(sx - px, sy - py);
|
||||
if (gap > bestPx || (best && gap === bestPx)) return;
|
||||
// 누가가 든 구간(꺾임점 끼울 자리)
|
||||
let segment = 0;
|
||||
for (let walked = 0; segment < line.vertices.length - 2; segment += 1) {
|
||||
walked += segmentLength(line.vertices[segment], line.vertices[segment + 1]);
|
||||
if (walked >= atM) break;
|
||||
}
|
||||
best = { line: index, atM, point, vertex: vertex >= 0 ? vertex : null, segment };
|
||||
bestPx = gap;
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 그은 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · (extra = 미리보기) · 시점(초록) · 종점(빨강).
|
||||
* `active` = 그리는 선(굵은 보라). */
|
||||
export function paintNetwork(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
network: PlanNetwork,
|
||||
base: { starts: Vertex[]; ends: Vertex[] },
|
||||
active: number | null,
|
||||
z: HeightAt,
|
||||
extra: () => void,
|
||||
): void {
|
||||
const { starts, ends } = base;
|
||||
const colors = planColors();
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
network.lines.forEach((line, index) => {
|
||||
if (line.vertices.length < 2) return;
|
||||
context.strokeStyle = index === active ? colors.active : colors.line;
|
||||
context.lineWidth = line.branch ? 2.5 : 3;
|
||||
context.beginPath();
|
||||
line.vertices.forEach((v, i) => {
|
||||
const [sx, sy] = map.toScreen(v);
|
||||
if (i) context.lineTo(sx, sy);
|
||||
else context.moveTo(sx, sy);
|
||||
});
|
||||
context.stroke();
|
||||
if (map.stations)
|
||||
drawStationTicks(
|
||||
context,
|
||||
line.vertices.map(([x, y]) => ({ x, y })),
|
||||
{
|
||||
intervalM: PLAN_STATION_INTERVAL_M,
|
||||
pxPerMeter: map.pxPerMeter,
|
||||
toScreen: (x, y) => map.toScreen([x, y]),
|
||||
uprightRad: map.uprightRad,
|
||||
chainages: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M),
|
||||
},
|
||||
);
|
||||
if (map.slopes) segmentLabels(context, map, line.vertices, z);
|
||||
});
|
||||
// 꺾임점
|
||||
context.fillStyle = "#ffffff";
|
||||
for (const line of network.lines)
|
||||
for (const v of line.vertices.slice(1)) {
|
||||
const [sx, sy] = map.toScreen(v);
|
||||
context.beginPath();
|
||||
context.arc(sx, sy, 3, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
drawPieces(context, map, network);
|
||||
extra();
|
||||
// 시점(초록) · 종점(빨강)
|
||||
for (const [points, fill] of [
|
||||
[starts, "#16a34a"],
|
||||
[ends, "#dc2626"],
|
||||
] as const)
|
||||
for (const point of points) {
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
context.beginPath();
|
||||
context.arc(sx, sy, 7, 0, Math.PI * 2);
|
||||
context.fillStyle = fill;
|
||||
context.fill();
|
||||
context.lineWidth = 2;
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function segmentLabels(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
vertices: Vertex[],
|
||||
z: HeightAt,
|
||||
): void {
|
||||
for (let i = 1; i < vertices.length; i += 1) {
|
||||
const [a, b] = [map.toScreen(vertices[i - 1]), map.toScreen(vertices[i])];
|
||||
if (Math.hypot(b[0] - a[0], b[1] - a[1]) < LABEL_MIN_PX) continue;
|
||||
const slope = groundSlopePct(vertices[i - 1], vertices[i], z);
|
||||
const text = `${fmt(segmentLength(vertices[i - 1], vertices[i]))} m · ${slope === null ? "—" : `${signed(slope)} %`}`;
|
||||
planLabel(context, map, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 - 12, text, "11px");
|
||||
}
|
||||
}
|
||||
|
||||
function drawPieces(context: CanvasRenderingContext2D, map: ScreenMap, network: PlanNetwork): void {
|
||||
const colors = planColors();
|
||||
network.pieces.forEach((piece) => {
|
||||
const vertices = network.lines.find((line) => line.id === piece.line)?.vertices;
|
||||
if (!vertices || vertices.length < 2) return;
|
||||
if (piece.from_m > 0) {
|
||||
const [sx, sy] = map.toScreen(pointAtDistance(vertices, piece.from_m));
|
||||
context.beginPath();
|
||||
context.moveTo(sx, sy - 8);
|
||||
context.lineTo(sx + 6, sy);
|
||||
context.lineTo(sx, sy + 8);
|
||||
context.lineTo(sx - 6, sy);
|
||||
context.closePath();
|
||||
context.fillStyle = colors.active;
|
||||
context.fill();
|
||||
context.lineWidth = 1.5;
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.stroke();
|
||||
}
|
||||
const [mx, my] = map.toScreen(pointAtDistance(vertices, (piece.from_m + piece.to_m) / 2));
|
||||
planLabel(context, map, mx, my + 16, `${piece.year}년`, "bold 12px");
|
||||
});
|
||||
}
|
||||
|
||||
/** 미리보기 — 점선 · 둘 다(양각기) 원 · 후보 · 글(거리 · 가상 종단 기울기 % · °). */
|
||||
export function paintPreview(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
from: Vertex,
|
||||
preview: DrawPreview,
|
||||
compass: { lengthM: number; candidates: Vertex[] } | null,
|
||||
): void {
|
||||
const colors = planColors();
|
||||
const [ax, ay] = map.toScreen(from);
|
||||
const [bx, by] = map.toScreen(preview.point);
|
||||
if (compass) {
|
||||
context.strokeStyle = colors.active;
|
||||
context.lineWidth = 1;
|
||||
context.setLineDash([3, 4]);
|
||||
context.beginPath();
|
||||
context.arc(ax, ay, compass.lengthM * map.pxPerMeter, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
context.fillStyle = colors.active;
|
||||
for (const c of compass.candidates) {
|
||||
const [cx, cy] = map.toScreen(c);
|
||||
context.beginPath();
|
||||
context.arc(cx, cy, 3, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
}
|
||||
context.strokeStyle = preview.blocked ? colors.warn : colors.active;
|
||||
context.lineWidth = 2;
|
||||
context.setLineDash([6, 4]);
|
||||
context.beginPath();
|
||||
context.moveTo(ax, ay);
|
||||
context.lineTo(bx, by);
|
||||
context.stroke();
|
||||
context.setLineDash([]);
|
||||
context.beginPath();
|
||||
context.arc(bx, by, 5, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
const value =
|
||||
`${fmt(preview.lengthM)} m · ` +
|
||||
(preview.slopePct === null ? "지형 밖" : slopeText(preview.slopePct));
|
||||
planLabel(
|
||||
context,
|
||||
map,
|
||||
bx + 14,
|
||||
by - 14,
|
||||
preview.note ? `${value} · ${preview.note}` : value,
|
||||
"12px",
|
||||
"left",
|
||||
);
|
||||
}
|
||||
|
||||
export function planLabel(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
font: string,
|
||||
align: CanvasTextAlign = "center",
|
||||
): void {
|
||||
map.upright(context, x, y, () => {
|
||||
context.font = `${font} system-ui, sans-serif`;
|
||||
context.textAlign = align;
|
||||
context.textBaseline = "middle";
|
||||
context.lineWidth = 3;
|
||||
context.strokeStyle = planColors().halo;
|
||||
context.strokeText(text, x, y);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillText(text, x, y);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Edit.ts
|
||||
* D03 고르기 · 고치기 겹(PLAN 580 · 요구서 440 3 · 6 · 7) — 쉴 때(긋기 · 연도 구분 · 재기 아님)만.
|
||||
*
|
||||
* 선 누름 = 그 선 · 조각 고름(굵게 · 좌측 「구역 · 연도」 그 줄 밝힘) · 빈 곳 = 고름 풂.
|
||||
* 고른 선 위 누름 = 꺾임점 더함(그대로 끌면 옮김) · 꺾임점 끌기 = 옮김(붙은 갈래 첫 점 같이) ·
|
||||
* 마크 끌기 = 연도 구분 옮김 · 오른쪽 누름 = 꺾임점 · 마크 지움 · Delete = 고른 선 지움.
|
||||
* 잠금 = 시점 · 종점에 놓인 점(1단계 몫) · 갈래 첫 점(붙은 선 점을 끌면 같이 감).
|
||||
* 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음.
|
||||
* 좌측 「고른 선」 칸 = 이름 · 길이 · [방향 바꾸기](측점 No.0 쪽 바꿈) · [선 지움].
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import {
|
||||
distanceAlong,
|
||||
lineLength,
|
||||
pointAtDistance,
|
||||
sliceLine,
|
||||
} from "./logic/D03_PlanAnalysis_Draw";
|
||||
import {
|
||||
insertVertex,
|
||||
lineNames,
|
||||
moveMark,
|
||||
moveVertex,
|
||||
removeLine,
|
||||
removeMark,
|
||||
removeVertex,
|
||||
reverseLine,
|
||||
samePoint,
|
||||
type PlanNetwork,
|
||||
type Vertex,
|
||||
} from "./logic/D03_PlanAnalysis_Plan";
|
||||
import { lineAt, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
|
||||
import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||||
|
||||
export interface PlanEditParams {
|
||||
network: () => PlanNetwork;
|
||||
base: () => { starts: Vertex[]; ends: Vertex[] };
|
||||
commit: (next: PlanNetwork) => void;
|
||||
/** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */
|
||||
show: (next: PlanNetwork) => void;
|
||||
/** 고르기 받는 때(쉴 때 · 재기 아님). */
|
||||
enabled: () => boolean;
|
||||
/** 고치기 됨(산림청). */
|
||||
editable: () => boolean;
|
||||
/** 고름이 바뀜 — 페이지가 좌측 줄 밝힘. */
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
export interface PlanSelection {
|
||||
line: string;
|
||||
piece: string | null;
|
||||
}
|
||||
|
||||
export interface PlanEdit {
|
||||
layer: PlanMapLayer;
|
||||
/** 좌측 「고른 선」 칸. */
|
||||
panel: HTMLElement;
|
||||
selected: () => PlanSelection | null;
|
||||
select: (line: string | null, piece?: string | null) => void;
|
||||
/** 키 — 받았으면 참(Delete · Esc). */
|
||||
key: (event: KeyboardEvent) => boolean;
|
||||
/** 되돌리기 · 저장 뒤 — 고른 선이 사라졌으면 풂 · 칸 다시. */
|
||||
sync: () => void;
|
||||
}
|
||||
|
||||
type Drag =
|
||||
| { kind: "vertex"; line: number; vertex: number; before: PlanNetwork; last: PlanNetwork | null }
|
||||
| { kind: "mark"; index: number; before: PlanNetwork; last: PlanNetwork | null }
|
||||
| { kind: "locked"; note: string; told: boolean };
|
||||
|
||||
export function createPlanEdit(params: PlanEditParams): PlanEdit {
|
||||
let selected: PlanSelection | null = null;
|
||||
let drag: Drag | null = null;
|
||||
|
||||
const info = createText("", "muted");
|
||||
const flipButton = createButton({
|
||||
label: "방향 바꾸기",
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () => apply((network, index) => reverseLine(network, index)),
|
||||
});
|
||||
flipButton.title = "측점 No.0 쪽(시점 쪽)을 반대 끝으로 바꿉니다.";
|
||||
const dropButton = createButton({
|
||||
label: "선 지움",
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () => apply((network, index) => removeLine(network, index)),
|
||||
});
|
||||
const row = document.createElement("div");
|
||||
row.className = "d03-plan__row";
|
||||
row.append(flipButton, dropButton);
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "d03-plan__pick";
|
||||
panel.dataset.role = "plan-pick";
|
||||
panel.append(info, row);
|
||||
|
||||
const indexOf = (network: PlanNetwork): number =>
|
||||
selected ? network.lines.findIndex((line) => line.id === selected!.line) : -1;
|
||||
|
||||
/** 고른 선에 꼴 바꿈 하나 — 까닭 글이면 알림. */
|
||||
function apply(step: (network: PlanNetwork, index: number) => PlanNetwork | string): void {
|
||||
const network = params.network();
|
||||
const index = indexOf(network);
|
||||
if (index < 0 || !params.editable()) return;
|
||||
const next = step(network, index);
|
||||
if (typeof next === "string") return void showToast(next, "info");
|
||||
params.commit(next);
|
||||
sync();
|
||||
}
|
||||
|
||||
function panelState(): void {
|
||||
const network = params.network();
|
||||
const index = indexOf(network);
|
||||
panel.hidden = index < 0;
|
||||
if (index < 0) return;
|
||||
const line = network.lines[index];
|
||||
info.textContent = `고른 선 — ${lineNames(network.lines)[index]} · ${lineLength(line.vertices).toFixed(1)} m`;
|
||||
flipButton.hidden = dropButton.hidden = !params.editable();
|
||||
flipButton.disabled = !!line.branch;
|
||||
}
|
||||
|
||||
function select(line: string | null, piece: string | null = null): void {
|
||||
selected = line ? { line, piece } : null;
|
||||
panelState();
|
||||
params.onSelect();
|
||||
}
|
||||
|
||||
function sync(): void {
|
||||
if (selected && indexOf(params.network()) < 0) selected = null;
|
||||
if (selected?.piece && !params.network().pieces.some((p) => p.id === selected!.piece))
|
||||
selected = { ...selected, piece: null };
|
||||
panelState();
|
||||
}
|
||||
|
||||
const pieceAt = (network: PlanNetwork, line: string, atM: number): string | null =>
|
||||
network.pieces.find((p) => p.line === line && atM >= p.from_m - 1e-6 && atM <= p.to_m + 1e-6)
|
||||
?.id ?? null;
|
||||
|
||||
/** 화면 px 둘레 마크(조각 시작) — 조각 차례. */
|
||||
function markAt(network: PlanNetwork, px: number, py: number, map: ScreenMap): number | null {
|
||||
let best: number | null = null;
|
||||
let bestPx = SNAP_PX;
|
||||
network.pieces.forEach((piece, index) => {
|
||||
const vertices = network.lines.find((line) => line.id === piece.line)?.vertices;
|
||||
if (!vertices || !(piece.from_m > 0)) return;
|
||||
const gap = screenGap(map, pointAtDistance(vertices, piece.from_m), px, py);
|
||||
if (gap <= bestPx) [best, bestPx] = [index, gap];
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 화면 px 둘레 꺾임점 — 갈래 첫 점은 붙은 선 점이 대신 집힘. */
|
||||
function vertexAt(
|
||||
network: PlanNetwork,
|
||||
px: number,
|
||||
py: number,
|
||||
map: ScreenMap,
|
||||
): { line: number; vertex: number } | null {
|
||||
let best: { line: number; vertex: number } | null = null;
|
||||
let bestPx = SNAP_PX;
|
||||
network.lines.forEach((line, i) =>
|
||||
line.vertices.forEach((point, v) => {
|
||||
if (v === 0 && line.branch) return;
|
||||
const gap = screenGap(map, point, px, py);
|
||||
if (gap <= bestPx) [best, bestPx] = [{ line: i, vertex: v }, gap];
|
||||
}),
|
||||
);
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 잠금 까닭 — 시점 · 종점에 놓인 점. */
|
||||
function lockOf(network: PlanNetwork, line: number, vertex: number): string | null {
|
||||
const point = network.lines[line].vertices[vertex];
|
||||
const { starts, ends } = params.base();
|
||||
return [...starts, ...ends].some((p) => samePoint(p, point))
|
||||
? "시점 · 종점 자리 점은 1단계(기준 지정)에서 옮깁니다."
|
||||
: null;
|
||||
}
|
||||
|
||||
const layer: PlanMapLayer = {
|
||||
draw(context, map) {
|
||||
const network = params.network();
|
||||
const index = indexOf(network);
|
||||
if (index < 0) return;
|
||||
const colors = planColors();
|
||||
const line = network.lines[index];
|
||||
const path = (points: Vertex[]): void => {
|
||||
context.beginPath();
|
||||
points.forEach((point, i) => {
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
if (i) context.lineTo(sx, sy);
|
||||
else context.moveTo(sx, sy);
|
||||
});
|
||||
context.stroke();
|
||||
};
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
context.strokeStyle = colors.active;
|
||||
context.globalAlpha = 0.45;
|
||||
context.lineWidth = 9;
|
||||
path(line.vertices);
|
||||
const piece = network.pieces.find((p) => p.id === selected?.piece);
|
||||
if (piece) {
|
||||
context.globalAlpha = 0.85;
|
||||
context.lineWidth = 6;
|
||||
path(sliceLine(line.vertices, piece.from_m, piece.to_m));
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
// 꺾임점 손잡이
|
||||
context.lineWidth = 2;
|
||||
context.fillStyle = "#ffffff";
|
||||
for (const point of line.vertices) {
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
context.fillRect(sx - 4, sy - 4, 8, 8);
|
||||
context.strokeRect(sx - 4, sy - 4, 8, 8);
|
||||
}
|
||||
},
|
||||
press(px, py, map, button) {
|
||||
if (!params.enabled()) return false;
|
||||
const network = params.network();
|
||||
const editable = params.editable();
|
||||
const mark = markAt(network, px, py, map);
|
||||
const vertex = vertexAt(network, px, py, map);
|
||||
if (button === 2) {
|
||||
if (!editable) return false;
|
||||
if (mark !== null) {
|
||||
const piece = network.pieces[mark];
|
||||
params.commit(removeMark(network, piece.line, piece.from_m, 1e-3));
|
||||
sync();
|
||||
return true;
|
||||
}
|
||||
if (!vertex) return false;
|
||||
const next = removeVertex(network, vertex.line, vertex.vertex);
|
||||
if (typeof next === "string") showToast(next, "info");
|
||||
else params.commit(next);
|
||||
sync();
|
||||
return true;
|
||||
}
|
||||
if (mark !== null) {
|
||||
const piece = network.pieces[mark];
|
||||
select(piece.line, piece.id);
|
||||
if (editable) drag = { kind: "mark", index: mark, before: network, last: null };
|
||||
return true;
|
||||
}
|
||||
if (vertex) {
|
||||
const id = network.lines[vertex.line].id;
|
||||
const atM = lineLength(network.lines[vertex.line].vertices.slice(0, vertex.vertex + 1));
|
||||
select(id, pieceAt(network, id, atM));
|
||||
const lock = lockOf(network, vertex.line, vertex.vertex);
|
||||
if (editable)
|
||||
drag = lock
|
||||
? { kind: "locked", note: lock, told: false }
|
||||
: { kind: "vertex", ...vertex, before: network, last: null };
|
||||
return true;
|
||||
}
|
||||
const hit = lineAt(network, px, py, map);
|
||||
if (hit) {
|
||||
const id = network.lines[hit.line].id;
|
||||
if (editable && selected?.line === id) {
|
||||
params.commit(insertVertex(network, hit.line, hit.segment, hit.point));
|
||||
const before = params.network();
|
||||
drag = { kind: "vertex", line: hit.line, vertex: hit.segment + 1, before, last: null };
|
||||
}
|
||||
select(id, pieceAt(params.network(), id, hit.atM));
|
||||
return true;
|
||||
}
|
||||
if (!selected) return false;
|
||||
select(null);
|
||||
return true;
|
||||
},
|
||||
hover(px, py, map) {
|
||||
if (!drag) return;
|
||||
if (drag.kind === "locked") {
|
||||
if (!drag.told) showToast(drag.note, "info");
|
||||
drag.told = true;
|
||||
return;
|
||||
}
|
||||
const cursor = map.toMetric(px, py);
|
||||
if (drag.kind === "vertex") {
|
||||
drag.last = moveVertex(drag.before, drag.line, drag.vertex, cursor);
|
||||
} else {
|
||||
const piece = drag.before.pieces[drag.index];
|
||||
const vertices = drag.before.lines.find((line) => line.id === piece.line)!.vertices;
|
||||
drag.last = moveMark(drag.before, drag.index, distanceAlong(vertices, cursor));
|
||||
}
|
||||
params.show(drag.last);
|
||||
},
|
||||
release() {
|
||||
const done = drag;
|
||||
drag = null;
|
||||
if (!done || done.kind === "locked" || !done.last) return;
|
||||
params.show(done.before);
|
||||
params.commit(done.last);
|
||||
sync();
|
||||
},
|
||||
};
|
||||
|
||||
panelState();
|
||||
|
||||
return {
|
||||
layer,
|
||||
panel,
|
||||
selected: () => selected,
|
||||
select,
|
||||
key(event) {
|
||||
if (!selected || !params.enabled()) return false;
|
||||
if (event.key === "Delete" && params.editable()) {
|
||||
apply((network, index) => removeLine(network, index));
|
||||
return true;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
select(null);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
sync,
|
||||
};
|
||||
}
|
||||
@@ -64,11 +64,12 @@ export interface ScreenMap {
|
||||
upright: (context: CanvasRenderingContext2D, x: number, y: number, paint: () => void) => void;
|
||||
}
|
||||
|
||||
/** 평면 위 겹 — 그리기 · 누름(참 = 받음 · 0 왼쪽 · 2 오른쪽) · 마우스 곁 · 나감. */
|
||||
/** 평면 위 겹 — 그리기 · 누름(참 = 받음 · 0 왼쪽 · 2 오른쪽) · 마우스 곁(왼쪽 끌기 중도) · 놓음 · 나감. */
|
||||
export interface PlanMapLayer {
|
||||
draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void;
|
||||
press?: (px: number, py: number, map: ScreenMap, button: 0 | 2) => boolean;
|
||||
hover?: (px: number, py: number, map: ScreenMap) => void;
|
||||
release?: () => void;
|
||||
leave?: () => void;
|
||||
}
|
||||
|
||||
@@ -152,11 +153,15 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
label: "평면 도구",
|
||||
hint: {
|
||||
lines: [
|
||||
"[긋기] 뒤 시점 · 종점 · 선 끝 · 선 위 누름 = 시작",
|
||||
"누름 = 점 찍기",
|
||||
"Enter = 긋기 끝",
|
||||
"Esc = 긋기 취소",
|
||||
"Backspace = 마지막 점 빼기",
|
||||
"오른쪽 누름 = 마크 지움",
|
||||
"선 누름 = 고름 · 고른 선 위 누름 = 꺾임점 더함",
|
||||
"꺾임점 · 마크 끌기 = 옮김",
|
||||
"오른쪽 누름 = 꺾임점 · 마크 지움",
|
||||
"Delete = 고른 선 지움",
|
||||
"가운데 끌기 = 옮기기",
|
||||
"휠 = 확대 · 축소",
|
||||
"Ctrl+휠 = 돌리기",
|
||||
@@ -270,7 +275,15 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
if (!meta || event.button !== 0) return;
|
||||
const [px, py] = at(event);
|
||||
const map = screenMap();
|
||||
if (layers.some((layer) => layer.press?.(px, py, map, 0))) draw();
|
||||
if (layers.some((layer) => layer.press?.(px, py, map, 0))) {
|
||||
canvas.setPointerCapture(event.pointerId); // 끌기가 캔버스 밖으로 나가도 놓음을 받음
|
||||
draw();
|
||||
}
|
||||
});
|
||||
canvas.addEventListener("pointerup", (event) => {
|
||||
if (event.button !== 0) return;
|
||||
for (const layer of layers) layer.release?.();
|
||||
draw();
|
||||
});
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
if (!meta || event.buttons & 4) return; // 가운데 단추 옮기는 중
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Page.ts
|
||||
* D 예정노선 2단계 노선분석(요구서 389 3장 6~13 · PLAN 562-3 · 563-1) — B05~B09 틀(`mountGroupStep`).
|
||||
* D 예정노선 2단계 노선분석(요구서 389 3장 6~13 · 440 · PLAN 562-3 · 563-1 · 579-3 · 580) — B05~B09 틀(`mountGroupStep`).
|
||||
*
|
||||
* 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 구역 마크) + 도구 여섯.
|
||||
* 좌 = 지형(범위를 카카오 지도에 먼저 보임 → [이 범위 받기] · [범위 밖 더 받기]는 선이 받은 범위 밖일 때만) ·
|
||||
* 그리기(본선 · 갈래 · 구역 마크 · 제약 칸) · 구역 · 연도(조각마다 연도) · [저장] [초기화].
|
||||
* 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 연도 구분) + 도구 여섯.
|
||||
* 좌 = 지형(들어올 때 받은 범위가 시점 · 종점을 못 덮으면 바로 받기 · [범위 밖 더 받기]는 시점 · 종점 · 선이
|
||||
* 받은 범위 밖일 때만) · 그리기([긋기] · [연도 구분] · 제약 칸 · 긋는 해 · 고른 선) ·
|
||||
* 구역 · 연도(선 통째 해 · 조각마다 해 · 고른 줄 밝힘) · [저장] [초기화].
|
||||
* 시점 · 종점 = D02 목록(여럿) · 고르기 · 고치기 = `D03_PlanAnalysis_UI_Edit`.
|
||||
*
|
||||
* 데이터 3층(CLAUDE.md 5장) — 긋기 · 마크 · 연도 = 캐시(`d03-plan` {version: 2, network}) · [저장] =
|
||||
* PUT plan/network(서버가 길이 · 측점 · 조각 · 연도 행을 다시 셈 · 응답으로 바꿈) · [초기화] = 빈 선 PUT.
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
} from "./D03_PlanAnalysis_Api";
|
||||
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
|
||||
import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw";
|
||||
import { createPlanEdit } from "./D03_PlanAnalysis_UI_Edit";
|
||||
import { createPlanLayers } from "./D03_PlanAnalysis_UI_Layers";
|
||||
import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
|
||||
import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure";
|
||||
@@ -53,9 +56,10 @@ import { lineLength, stationChainages } from "./logic/D03_PlanAnalysis_Draw";
|
||||
import {
|
||||
emptyNetwork,
|
||||
fromServer,
|
||||
lineName,
|
||||
lineNames,
|
||||
marksOf,
|
||||
normalizePieces,
|
||||
setLineYear,
|
||||
setPieceYear,
|
||||
type PlanNetwork,
|
||||
type Vertex,
|
||||
@@ -82,11 +86,12 @@ const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M,
|
||||
export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const projectId = currentProjectId();
|
||||
let editable = false;
|
||||
let start: Vertex = [0, 0];
|
||||
let end: Vertex = [0, 0];
|
||||
let starts: Vertex[] = [];
|
||||
let ends: Vertex[] = [];
|
||||
let epsg = 0;
|
||||
let yearStart = new Date().getFullYear();
|
||||
let network: PlanNetwork = emptyNetwork(start);
|
||||
let drawYear = yearStart; // 긋는 해 — 새 조각 처음 해
|
||||
let network: PlanNetwork = emptyNetwork();
|
||||
let savedJson = JSON.stringify(network); // 못 읽은 채 떠남 = 고침 없음
|
||||
let undoStack: PlanNetwork[] = [];
|
||||
let redoStack: PlanNetwork[] = [];
|
||||
@@ -99,14 +104,31 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
|
||||
const z = (x: number, y: number): number => (surface ? gridHeightAt(surface.grid, x, y) : NaN);
|
||||
const draw = createPlanDraw({
|
||||
base: () => ({ start, end }),
|
||||
base: () => ({ starts, ends }),
|
||||
network: () => network,
|
||||
heightAt: () => z,
|
||||
commit,
|
||||
year: () => drawYear,
|
||||
onMode: () => {
|
||||
if (draw.mode() !== "idle") setMeasure(false);
|
||||
refresh();
|
||||
},
|
||||
onMark: (piece) => {
|
||||
edit.select(network.pieces.find((p) => p.id === piece)?.line ?? null, piece);
|
||||
openYear(piece);
|
||||
},
|
||||
});
|
||||
const edit = createPlanEdit({
|
||||
network: () => network,
|
||||
base: () => ({ starts, ends }),
|
||||
commit,
|
||||
show: (next) => {
|
||||
network = normalizePieces(next, drawYear);
|
||||
map.redraw();
|
||||
},
|
||||
enabled: () => draw.mode() === "idle" && !measuring,
|
||||
editable: () => editable,
|
||||
onSelect: () => refresh(),
|
||||
});
|
||||
const measure = createPlanMeasure(() => z);
|
||||
const map = createPlanMap({
|
||||
@@ -118,7 +140,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const backdrop = createPlanLayers({
|
||||
projectId,
|
||||
map,
|
||||
points: () => [start, end, ...allVertices()],
|
||||
points: () => [...starts, ...ends, ...allVertices()],
|
||||
});
|
||||
map.root.append(backdrop.bar, backdrop.legend);
|
||||
const main = document.createElement("div");
|
||||
@@ -143,8 +165,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
});
|
||||
const terrainCard = createPanelCard("지형", false);
|
||||
terrainCard.append(terrainText, bar.root, extentButton, moreButton);
|
||||
const drawYearPick = createSelectField({
|
||||
label: "긋는 해",
|
||||
options: [],
|
||||
size: 24,
|
||||
ariaLabel: "긋는 해",
|
||||
onChange: (value) => (drawYear = Number(value)),
|
||||
});
|
||||
drawYearPick.select.dataset.role = "draw-year";
|
||||
const drawCard = createPanelCard("그리기", false);
|
||||
drawCard.append(draw.panel);
|
||||
drawCard.append(draw.panel, drawYearPick.root, edit.panel);
|
||||
const pieceList = document.createElement("div");
|
||||
pieceList.className = "d03-plan__pieces";
|
||||
const pieceCard = createPanelCard("구역 · 연도", false);
|
||||
@@ -183,7 +213,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
function commit(next: PlanNetwork): void {
|
||||
undoStack = [...undoStack.slice(1 - UNDO_MAX), network];
|
||||
redoStack = [];
|
||||
network = normalizePieces(next, yearStart);
|
||||
network = normalizePieces(next, drawYear);
|
||||
keepDraft();
|
||||
refresh();
|
||||
}
|
||||
@@ -211,17 +241,25 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
measuring = on;
|
||||
measure.clear();
|
||||
if (on) draw.setMode("idle");
|
||||
map.setLayers([...backdrop.layers, draw.layer, ...(measuring ? [measure.layer] : [])]);
|
||||
map.setLayers([
|
||||
...backdrop.layers,
|
||||
draw.layer,
|
||||
edit.layer,
|
||||
...(measuring ? [measure.layer] : []),
|
||||
]);
|
||||
refresh();
|
||||
}
|
||||
|
||||
/** 꺾임점이 받은 범위 밖인가(받은 범위 없음 = 밖 아님 — 그땐 [지형 범위 보기]). */
|
||||
/** 시점 · 종점 · 꺾임점이 받은 범위 밖인가(받은 범위 없음 = 밖 아님 — 그땐 [지형 범위 보기]). */
|
||||
const outside = (): boolean =>
|
||||
!!have &&
|
||||
allVertices().some(([x, y]) => x < have![0] || x > have![2] || y < have![1] || y > have![3]);
|
||||
[...starts, ...ends, ...allVertices()].some(
|
||||
([x, y]) => x < have![0] || x > have![2] || y < have![1] || y > have![3],
|
||||
);
|
||||
|
||||
function refresh(): void {
|
||||
draw.sync();
|
||||
edit.sync();
|
||||
map.setTools({
|
||||
undo: editable && undoStack.length > 0,
|
||||
redo: editable && redoStack.length > 0,
|
||||
@@ -238,7 +276,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
: !surface
|
||||
? "받은 지형 없음 — 범위를 보고 받으세요(미리보기 길이 · 기울기는 지형을 받은 뒤)."
|
||||
: outside()
|
||||
? "그은 선이 받은 지형 밖으로 나갔습니다 — 더 받아야 기울기를 셉니다."
|
||||
? "시점 · 종점 또는 그은 선이 받은 지형 밖입니다 — 더 받아야 기울기를 셉니다."
|
||||
: "받은 지형 안";
|
||||
if (failed && editable && !busy) extentButton.hidden = false;
|
||||
extentButton.querySelector(".ui-btn__label")!.textContent = failed
|
||||
@@ -248,23 +286,59 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
saveButton.disabled = busy || !dirty();
|
||||
}
|
||||
|
||||
function renderPieces(): void {
|
||||
/** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */
|
||||
function yearOptions(): Array<{ value: string; text: string }> {
|
||||
const years = new Set(Array.from({ length: YEAR_SPAN }, (_, i) => yearStart + i));
|
||||
network.pieces.forEach((piece) => years.add(piece.year));
|
||||
const options = [...years].sort().map((year) => ({ value: String(year), text: `${year}년` }));
|
||||
return [...years].sort().map((year) => ({ value: String(year), text: `${year}년` }));
|
||||
}
|
||||
|
||||
/** 연도 구분 마크를 놓은 뒤 — 뒤 조각 해 고름을 엶. */
|
||||
function openYear(piece: string): void {
|
||||
// 지도 누름의 기본 동작(초점 걷음)이 끝난 뒤
|
||||
setTimeout(() => {
|
||||
const pick = pieceList.querySelector<HTMLSelectElement>(`[data-piece="${piece}"] select`);
|
||||
if (!pick) return;
|
||||
pick.scrollIntoView({ block: "nearest" });
|
||||
pick.focus();
|
||||
try {
|
||||
pick.showPicker();
|
||||
} catch {
|
||||
// 사용자 누름 밖이면 못 엶 — 초점만
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderPieces(): void {
|
||||
const options = yearOptions();
|
||||
const names = lineNames(network.lines);
|
||||
const picked = edit.selected();
|
||||
const rows: HTMLElement[] = [];
|
||||
network.lines.forEach((line, index) => {
|
||||
const pieces = network.pieces.filter((piece) => piece.line === line.id);
|
||||
if (!pieces.length) return;
|
||||
const head = createText(
|
||||
`${lineName(index)} · ${lineLength(line.vertices).toFixed(1)} m`,
|
||||
"muted",
|
||||
const years = new Set(pieces.map((piece) => piece.year));
|
||||
const whole = createSelectField({
|
||||
options: years.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options,
|
||||
value: years.size > 1 ? "" : String(pieces[0].year),
|
||||
size: 24,
|
||||
ariaLabel: "선 통째 연도",
|
||||
disabled: !editable,
|
||||
onChange: (value) => value && commit(setLineYear(network, line.id, Number(value))),
|
||||
});
|
||||
const head = document.createElement("div");
|
||||
head.className = "d03-plan__piece d03-plan__piece-head";
|
||||
head.classList.toggle("is-selected", picked?.line === line.id && !picked.piece);
|
||||
head.dataset.line = line.id;
|
||||
head.append(
|
||||
createText(`${names[index]} · ${lineLength(line.vertices).toFixed(1)} m`, "muted"),
|
||||
whole.root,
|
||||
);
|
||||
head.classList.add("d03-plan__piece-head");
|
||||
rows.push(head);
|
||||
for (const piece of pieces) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "d03-plan__piece";
|
||||
row.classList.toggle("is-selected", picked?.piece === piece.id);
|
||||
row.dataset.piece = piece.id;
|
||||
const span = createText(
|
||||
`${station(piece.from_m)} ~ ${station(piece.to_m)} (${(piece.to_m - piece.from_m).toFixed(1)} m)`,
|
||||
@@ -425,7 +499,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
network =
|
||||
server && server.lines.length
|
||||
? fromServer(server.lines, server.marks, server.pieces, yearStart)
|
||||
: emptyNetwork(start);
|
||||
: emptyNetwork();
|
||||
if (server?.epsg) epsg = server.epsg;
|
||||
savedJson = JSON.stringify(network);
|
||||
}
|
||||
@@ -484,9 +558,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
|
||||
function open3d(): void {
|
||||
if (!surface) return;
|
||||
const names = lineNames(network.lines);
|
||||
const lines = network.lines
|
||||
.map((line, index) => ({
|
||||
name: lineName(index),
|
||||
name: names[index],
|
||||
line: line.vertices,
|
||||
stations: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M),
|
||||
}))
|
||||
@@ -497,9 +572,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
// 검증 창구 — 꼴 · 미리보기 · 제약 · 화면 자리 · 재기 · 회전 · 저장 시간(ORCA 판정)
|
||||
(window as unknown as { __d03Plan?: unknown }).__d03Plan = {
|
||||
network: () => network,
|
||||
base: () => ({ start, end }),
|
||||
base: () => ({ starts, ends }),
|
||||
lineNames: () => lineNames(network.lines),
|
||||
screenOf: map.screenOf,
|
||||
preview: draw.preview,
|
||||
stroke: draw.stroke,
|
||||
selected: edit.selected,
|
||||
select: edit.select,
|
||||
drawYear: () => drawYear,
|
||||
constraint: draw.constraint,
|
||||
setConstraint: draw.setConstraint,
|
||||
mode: draw.mode,
|
||||
@@ -525,36 +605,44 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
? (redo(), true)
|
||||
: measuring && event.key === "Escape"
|
||||
? (setMeasure(false), true)
|
||||
: !ctrl && draw.key(event);
|
||||
: !ctrl && (draw.key(event) || edit.key(event));
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
map.redraw();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
map.setLayers([...backdrop.layers, draw.layer]);
|
||||
map.setLayers([...backdrop.layers, draw.layer, edit.layer]);
|
||||
|
||||
try {
|
||||
editable = !!(await fetchSessionUser())?.is_forest_service;
|
||||
draw.setEditable(editable);
|
||||
const view = await fetchPlanNetwork(projectId);
|
||||
const base = view.base;
|
||||
if (!base.start || !base.end || !base.epsg) {
|
||||
if (!base.starts.length || !base.ends.length || !base.epsg) {
|
||||
terrainText.textContent = "시점 · 종점이 비었습니다 — 1단계(기준 지정)에서 정하세요.";
|
||||
return;
|
||||
}
|
||||
start = [base.start.x, base.start.y];
|
||||
end = [base.end.x, base.end.y];
|
||||
starts = base.starts.map((p): Vertex => [p.x, p.y]);
|
||||
ends = base.ends.map((p): Vertex => [p.x, p.y]);
|
||||
epsg = base.epsg;
|
||||
takeServer(view);
|
||||
drawYear = yearStart;
|
||||
drawYearPick.setOptions(yearOptions(), String(drawYear));
|
||||
const draft = readState<Draft>("d03-plan", projectId);
|
||||
if (draft?.version === DRAFT_VERSION && draft.network?.lines?.length) network = draft.network;
|
||||
map.update({ fit: [start, end, ...allVertices()] });
|
||||
map.update({ fit: [...starts, ...ends, ...allVertices()] });
|
||||
refresh();
|
||||
await loadSurface();
|
||||
refresh();
|
||||
await backdrop.load();
|
||||
if ((await fetchPlanProgress(projectId)).terrain.running) await follow();
|
||||
const job = (await fetchPlanProgress(projectId)).terrain;
|
||||
if (job.running) await follow();
|
||||
else if (editable && !job.error) {
|
||||
// 들어올 때 받은 범위가 시점 · 종점을 못 덮으면 바로 받기(요구 441 1 · 실패한 뒤엔 [다시 받기])
|
||||
const extent = await fetchTerrainExtent(projectId, null, []);
|
||||
if (!extent.covered) await download(extent.margin_m, []);
|
||||
}
|
||||
} catch (error) {
|
||||
terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`;
|
||||
}
|
||||
|
||||
@@ -124,3 +124,9 @@
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* 580-3 고른 선 · 조각 줄 밝힘 */
|
||||
.d03-plan__piece.is-selected {
|
||||
background: var(--color-accent-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_Plan.ts
|
||||
* D 예정노선 2단계 손 긋기 꼴(PLAN 562-3) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기.
|
||||
* D 예정노선 2단계 손 긋기 꼴(PLAN 562-3 · 580) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기 ·
|
||||
* 선 뒤집기 · 잇기 · 꺾임점 옮김 · 지움 · 선 지움 · 선 통째 연도 · 마크 옮김.
|
||||
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
|
||||
* [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { lineLength } from "./D03_PlanAnalysis_Draw";
|
||||
import { lineLength, segmentLength } from "./D03_PlanAnalysis_Draw";
|
||||
|
||||
export type Vertex = [number, number];
|
||||
|
||||
/** 그은 선 하나 — 첫 선 = 본선(D02 시점에서) · 둘째부터 = 갈래(`branch` = 붙은 선 · 꺾임점 차례). */
|
||||
/** 그은 선 하나 — `branch` 없음 = 본선(시점 · 종점에서) · 있음 = 갈래(붙은 선 · 꺾임점 차례) ·
|
||||
* 꼭짓점 차례 = 측점 차례(첫 점 = No.0). */
|
||||
export interface PlanLine {
|
||||
id: string;
|
||||
vertices: Vertex[];
|
||||
@@ -32,8 +34,11 @@ export interface PlanNetwork {
|
||||
|
||||
/** 마크가 끝 · 다른 마크와 이보다 가까우면 안 놓음(m). */
|
||||
export const MARK_GAP_M = 1;
|
||||
/** 같은 점(m) — 서버 1 mm 와 같음. */
|
||||
const SAME_M = 1e-3;
|
||||
|
||||
const round3 = (value: number): number => Math.round(value * 1000) / 1000;
|
||||
export const samePoint = (a: Vertex, b: Vertex): boolean => segmentLength(a, b) <= SAME_M;
|
||||
|
||||
/** 새 조각 id — `p<n>`(있는 것 중 가장 큰 수 + 1). */
|
||||
function nextPieceId(pieces: ReadonlyArray<PlanPiece>): string {
|
||||
@@ -41,9 +46,28 @@ function nextPieceId(pieces: ReadonlyArray<PlanPiece>): string {
|
||||
return `p${Math.max(0, ...used) + 1}`;
|
||||
}
|
||||
|
||||
/** 빈 선 — 본선 시점만. */
|
||||
export function emptyNetwork(start: Vertex): PlanNetwork {
|
||||
return { lines: [{ id: "main", vertices: [start], branch: null }], pieces: [] };
|
||||
/** 빈 꼴 — 선 없음(긋기 = 시점 · 종점 · 선에서 시작). */
|
||||
export function emptyNetwork(): PlanNetwork {
|
||||
return { lines: [], pieces: [] };
|
||||
}
|
||||
|
||||
/** 새 선 id — 처음 본선 = `main` · 그 밖 `<prefix>-<n>`. */
|
||||
function nextLineId(lines: ReadonlyArray<PlanLine>, prefix: "line" | "branch"): string {
|
||||
const used = new Set(lines.map((entry) => entry.id));
|
||||
if (prefix === "line" && !used.has("main")) return "main";
|
||||
let n = lines.length;
|
||||
while (used.has(`${prefix}-${n}`)) n += 1;
|
||||
return `${prefix}-${n}`;
|
||||
}
|
||||
|
||||
/** 새 본선 — `point` 한 점(끝에 꺾임점을 더해 감). */
|
||||
export function newLine(network: PlanNetwork, point: Vertex): PlanNetwork {
|
||||
const line: PlanLine = {
|
||||
id: nextLineId(network.lines, "line"),
|
||||
vertices: [[point[0], point[1]]],
|
||||
branch: null,
|
||||
};
|
||||
return { ...network, lines: [...network.lines, line] };
|
||||
}
|
||||
|
||||
/** 선마다 조각이 0 ~ 길이를 빈틈없이 덮게 맞춤 — 경계 = 조각 시작(0 · 끝 근처 빼고) · 연도 = 그 자리 조각 ·
|
||||
@@ -186,14 +210,12 @@ export function dropLastLine(network: PlanNetwork): PlanNetwork {
|
||||
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;
|
||||
const id = nextLineId(network.lines, "branch");
|
||||
return {
|
||||
...network,
|
||||
lines: [
|
||||
...network.lines,
|
||||
{ id: `branch-${n}`, vertices: [[from[0], from[1]]], branch: { line: parent.id, vertex } },
|
||||
{ id, vertices: [[from[0], from[1]]], branch: { line: parent.id, vertex } },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -223,14 +245,262 @@ export function marksOf(network: PlanNetwork): Array<{ line: string; at_m: numbe
|
||||
.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 …) · 갈래 = 갈래 1 · 2 …(선 차례).
|
||||
* ⚠ 짝 = `D04_PlanConfirm_Repository.line_names`(조각 · 내보내기 이름) — 고치면 같이(거울 시험 530-3). */
|
||||
export function lineNames(lines: ReadonlyArray<Pick<PlanLine, "branch">>): string[] {
|
||||
const roots = lines.filter((line) => !line.branch).length;
|
||||
let root = 0;
|
||||
let branch = 0;
|
||||
return lines.map((line) =>
|
||||
line.branch ? `갈래 ${++branch}` : roots > 1 ? `본선 ${++root}` : "본선",
|
||||
);
|
||||
}
|
||||
|
||||
/** 선 이름 — 본선 · 갈래 1 · 갈래 2 …(선 차례). */
|
||||
export function lineName(index: number): string {
|
||||
return index === 0 ? "본선" : `갈래 ${index}`;
|
||||
/** 선 차례 — 갈래는 부모 뒤(서버 셈이 앞에서부터 부모를 찾음) · 이미 맞으면 그대로. */
|
||||
export function orderLines(lines: ReadonlyArray<PlanLine>): PlanLine[] {
|
||||
const done = new Set<string>();
|
||||
const out: PlanLine[] = [];
|
||||
let rest = [...lines];
|
||||
while (rest.length) {
|
||||
const left: PlanLine[] = [];
|
||||
for (const line of rest) {
|
||||
if (!line.branch || done.has(line.branch.line)) {
|
||||
out.push(line);
|
||||
done.add(line.id);
|
||||
} else left.push(line);
|
||||
}
|
||||
if (left.length === rest.length) return [...out, ...left];
|
||||
rest = left;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `id` 선과 그 아래 갈래 모두. */
|
||||
function family(network: PlanNetwork, id: string): Set<string> {
|
||||
const out = new Set([id]);
|
||||
for (let grew = true; grew;) {
|
||||
grew = false;
|
||||
for (const line of network.lines)
|
||||
if (line.branch && out.has(line.branch.line) && !out.has(line.id)) {
|
||||
out.add(line.id);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 꺾임점마다 선 위 거리. */
|
||||
function chainages(vertices: ReadonlyArray<Vertex>): number[] {
|
||||
const out = [0];
|
||||
for (let i = 1; i < vertices.length; i += 1)
|
||||
out.push(out[i - 1] + segmentLength(vertices[i - 1], vertices[i]));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 선 모양이 바뀜 — 그 선 마크(조각 시작)를 `carry(옛 거리)` 로 옮겨 실음 · 끝은 `normalizePieces` 가 맞춤. */
|
||||
function carryMarks(
|
||||
pieces: ReadonlyArray<PlanPiece>,
|
||||
line: string,
|
||||
carry: (m: number) => number,
|
||||
): PlanPiece[] {
|
||||
return pieces.map((piece) =>
|
||||
piece.line === line && piece.from_m > 0
|
||||
? { ...piece, from_m: round3(carry(piece.from_m)) }
|
||||
: piece,
|
||||
);
|
||||
}
|
||||
|
||||
/** 옛 모양 위 거리 → 같은 구간 · 같은 비율의 새 모양 위 거리(꺾임점 수 같음). */
|
||||
function sameShare(
|
||||
before: ReadonlyArray<Vertex>,
|
||||
after: ReadonlyArray<Vertex>,
|
||||
): (m: number) => number {
|
||||
const [a, b] = [chainages(before), chainages(after)];
|
||||
return (m) => {
|
||||
const found = a.findIndex((s, k) => k > 0 && m <= s);
|
||||
const i = found < 0 ? a.length - 2 : found - 1;
|
||||
const span = a[i + 1] - a[i];
|
||||
return b[i] + (span > 0 ? (m - a[i]) / span : 0) * (b[i + 1] - b[i]);
|
||||
};
|
||||
}
|
||||
|
||||
/** 꺾임점 옮김 — 이 점에 붙은 갈래 첫 점도 같이 · 마크는 구간 안 비율 그대로. */
|
||||
export function moveVertex(
|
||||
network: PlanNetwork,
|
||||
line: number,
|
||||
vertex: number,
|
||||
point: Vertex,
|
||||
): PlanNetwork {
|
||||
const moved = new Map<string, number>(); // 선 id → 옮길 꺾임점
|
||||
const visit = (id: string, v: number): void => {
|
||||
moved.set(id, v);
|
||||
for (const other of network.lines)
|
||||
if (other.branch?.line === id && other.branch.vertex === v) visit(other.id, 0);
|
||||
};
|
||||
visit(network.lines[line].id, vertex);
|
||||
let pieces = network.pieces;
|
||||
const lines = network.lines.map((entry) => {
|
||||
const v = moved.get(entry.id);
|
||||
if (v === undefined) return entry;
|
||||
const vertices = entry.vertices.map((p, i): Vertex => (i === v ? [point[0], point[1]] : p));
|
||||
pieces = carryMarks(pieces, entry.id, sameShare(entry.vertices, vertices));
|
||||
return { ...entry, vertices };
|
||||
});
|
||||
return { lines, pieces };
|
||||
}
|
||||
|
||||
/** 꺾임점 지움 — 갈래 첫 점 · 갈래가 붙은 점 · 점 둘뿐인 선은 못 지움(까닭 글) · 뒤 갈래 차례 −1 ·
|
||||
* 마크는 합친 구간 안 비율. */
|
||||
export function removeVertex(
|
||||
network: PlanNetwork,
|
||||
line: number,
|
||||
vertex: number,
|
||||
): PlanNetwork | string {
|
||||
const entry = network.lines[line];
|
||||
if (entry.vertices.length <= 2) return "점이 둘뿐인 선입니다 — 선을 지우세요.";
|
||||
if (vertex === 0 && entry.branch) return "갈래 첫 점은 붙은 선 위라 지울 수 없습니다.";
|
||||
if (network.lines.some((o) => o.branch?.line === entry.id && o.branch.vertex === vertex))
|
||||
return "갈래가 붙은 점은 지울 수 없습니다 — 갈래를 먼저 지우세요.";
|
||||
const vertices = entry.vertices.filter((_, i) => i !== vertex);
|
||||
const [a, b] = [chainages(entry.vertices), chainages(vertices)];
|
||||
const last = entry.vertices.length - 1;
|
||||
const carry = (m: number): number => {
|
||||
if (vertex === 0) return Math.max(0, m - a[1]);
|
||||
if (vertex === last) return Math.min(m, b[b.length - 1]);
|
||||
if (m <= a[vertex - 1]) return m;
|
||||
if (m >= a[vertex + 1]) return m - a[vertex + 1] + b[vertex];
|
||||
const share = (m - a[vertex - 1]) / (a[vertex + 1] - a[vertex - 1]);
|
||||
return b[vertex - 1] + share * (b[vertex] - b[vertex - 1]);
|
||||
};
|
||||
return {
|
||||
lines: network.lines.map((other, i) => {
|
||||
if (i === line) return { ...other, vertices };
|
||||
const branch = other.branch;
|
||||
return branch?.line === entry.id && branch.vertex > vertex
|
||||
? { ...other, branch: { ...branch, vertex: branch.vertex - 1 } }
|
||||
: other;
|
||||
}),
|
||||
pieces: carryMarks(network.pieces, entry.id, carry),
|
||||
};
|
||||
}
|
||||
|
||||
/** 선 방향 바꿈(측점 No.0 쪽 바꿈) — 조각 · 붙은 갈래 차례 따라감 · 갈래는 못 바꿈(까닭 글). */
|
||||
export function reverseLine(network: PlanNetwork, line: number): PlanNetwork | string {
|
||||
const entry = network.lines[line];
|
||||
if (entry.branch) return "갈래는 방향을 바꿀 수 없습니다 — 첫 점이 붙은 선 위입니다.";
|
||||
const n = entry.vertices.length;
|
||||
const length = round3(lineLength(entry.vertices));
|
||||
return {
|
||||
lines: network.lines.map((other, i) => {
|
||||
if (i === line) return { ...other, vertices: [...other.vertices].reverse() };
|
||||
const branch = other.branch;
|
||||
return branch?.line === entry.id
|
||||
? { ...other, branch: { ...branch, vertex: n - 1 - branch.vertex } }
|
||||
: other;
|
||||
}),
|
||||
pieces: network.pieces.map((piece) =>
|
||||
piece.line === entry.id
|
||||
? { ...piece, from_m: round3(length - piece.to_m), to_m: round3(length - piece.from_m) }
|
||||
: piece,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 선 지움 — 그 선 조각도 · 갈래가 붙어 있으면 못 지움(까닭 글). */
|
||||
export function removeLine(network: PlanNetwork, line: number): PlanNetwork | string {
|
||||
const id = network.lines[line]?.id;
|
||||
if (network.lines.some((other) => other.branch?.line === id))
|
||||
return "갈래가 붙은 선은 지울 수 없습니다 — 갈래를 먼저 지우세요.";
|
||||
return {
|
||||
lines: network.lines.filter((_, i) => i !== line),
|
||||
pieces: network.pieces.filter((piece) => piece.line !== id),
|
||||
};
|
||||
}
|
||||
|
||||
/** `a` 선 꼬리에 `b` 선(머리 = a 꼬리)을 이어 한 선 — 조각 id 그대로 · 이음 자리 두 조각이 같은 해면 하나로. */
|
||||
function joinLines(network: PlanNetwork, a: number, b: number): PlanNetwork {
|
||||
const [head, tail] = [network.lines[a], network.lines[b]];
|
||||
const offset = round3(lineLength(head.vertices));
|
||||
const shift = head.vertices.length - 1;
|
||||
const last = network.pieces
|
||||
.filter((piece) => piece.line === head.id)
|
||||
.sort((x, y) => y.from_m - x.from_m)[0];
|
||||
const pieces = network.pieces
|
||||
.map((piece) =>
|
||||
piece.line === tail.id
|
||||
? {
|
||||
...piece,
|
||||
line: head.id,
|
||||
from_m: round3(piece.from_m + offset),
|
||||
to_m: round3(piece.to_m + offset),
|
||||
}
|
||||
: piece,
|
||||
)
|
||||
.filter(
|
||||
(piece) =>
|
||||
!(
|
||||
piece.line === head.id &&
|
||||
Math.abs(piece.from_m - offset) < SAME_M &&
|
||||
last?.year === piece.year
|
||||
),
|
||||
);
|
||||
const lines = network.lines
|
||||
.map((line, i) => {
|
||||
if (i === a) return { ...line, vertices: [...line.vertices, ...tail.vertices.slice(1)] };
|
||||
const branch = line.branch;
|
||||
return branch?.line === tail.id
|
||||
? { ...line, branch: { line: head.id, vertex: branch.vertex + shift } }
|
||||
: line;
|
||||
})
|
||||
.filter((_, i) => i !== b);
|
||||
return { lines: orderLines(lines), pieces };
|
||||
}
|
||||
|
||||
/** 갈래 없는 선 머리가 다른 선 꼬리에 닿으면 이어 붙이고 · 꺾임점에 닿으면 그 점의 갈래로(제 갈래 빼고). */
|
||||
export function attachHead(network: PlanNetwork, line: number): PlanNetwork {
|
||||
const entry = network.lines[line];
|
||||
if (!entry || entry.branch || entry.vertices.length < 2) return network;
|
||||
const head = entry.vertices[0];
|
||||
const own = family(network, entry.id);
|
||||
const others = network.lines
|
||||
.map((other, index) => ({ other, index }))
|
||||
.filter(({ other }) => !own.has(other.id) && other.vertices.length > 0);
|
||||
const tail = others.find(({ other }) =>
|
||||
samePoint(other.vertices[other.vertices.length - 1], head),
|
||||
);
|
||||
if (tail) return joinLines(network, tail.index, line);
|
||||
for (const { other } of others) {
|
||||
const vertex = other.vertices.findIndex((v) => samePoint(v, head));
|
||||
if (vertex < 0) continue;
|
||||
const lines = network.lines.map((entry, i) =>
|
||||
i === line ? { ...entry, branch: { line: other.id, vertex } } : entry,
|
||||
);
|
||||
return { ...network, lines: orderLines(lines) };
|
||||
}
|
||||
return network;
|
||||
}
|
||||
|
||||
/** 선 통째 연도 — 그 선 조각 모두. */
|
||||
export function setLineYear(network: PlanNetwork, line: string, year: number): PlanNetwork {
|
||||
return {
|
||||
...network,
|
||||
pieces: network.pieces.map((piece) => (piece.line === line ? { ...piece, year } : piece)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 마크 옮김 — `index` 조각의 시작(= 앞 조각 끝)을 `atM` 으로 · 양쪽 조각 안에서 `MARK_GAP_M` 남김. */
|
||||
export function moveMark(network: PlanNetwork, index: number, atM: number): PlanNetwork {
|
||||
const piece = network.pieces[index];
|
||||
if (!piece || !(piece.from_m > 0)) return network;
|
||||
const before = network.pieces.find(
|
||||
(p) => p !== piece && p.line === piece.line && Math.abs(p.to_m - piece.from_m) < SAME_M,
|
||||
);
|
||||
if (!before) return network;
|
||||
const at = round3(Math.min(piece.to_m - MARK_GAP_M, Math.max(before.from_m + MARK_GAP_M, atM)));
|
||||
return {
|
||||
...network,
|
||||
pieces: network.pieces.map((p) =>
|
||||
p === piece ? { ...p, from_m: at } : p === before ? { ...p, to_m: at } : p,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,8 +41,24 @@ async def list_year_routes(project_id: str) -> list[dict[str, Any]]:
|
||||
return rows
|
||||
|
||||
|
||||
def line_names(lines: list[dict[str, Any]]) -> list[str]:
|
||||
"""선 이름 — 갈래 없는 선 = 본선(둘 이상이면 본선 1 · 2 …) · 갈래 = 갈래 1 · 2 …(선 차례).
|
||||
|
||||
⚠ 짝 = TS `D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts` `lineNames` — 거울 시험 530-3."""
|
||||
roots = sum(1 for line in lines if not line.get("branch"))
|
||||
out, root, branch = [], 0, 0
|
||||
for line in lines:
|
||||
if line.get("branch"):
|
||||
branch += 1
|
||||
out.append(f"갈래 {branch}")
|
||||
else:
|
||||
root += 1
|
||||
out.append(f"본선 {root}" if roots > 1 else "본선")
|
||||
return out
|
||||
|
||||
|
||||
async def list_pieces(project_id: str) -> list[dict[str, Any]]:
|
||||
"""조각 행(연도 · 선 차례 · 선 위 거리 차례) + `line_name`(본선 · 갈래 N = 정본 선 차례)."""
|
||||
"""조각 행(연도 · 선 차례 · 선 위 거리 차례) + `line_name`(`line_names` = 정본 선 차례)."""
|
||||
async with (
|
||||
get_db_pool().acquire() as connection,
|
||||
connection.cursor(aiomysql.DictCursor) as cursor,
|
||||
@@ -61,13 +77,13 @@ async def list_pieces(project_id: str) -> list[dict[str, Any]]:
|
||||
if network:
|
||||
raw = network["network_json"]
|
||||
body = json.loads(raw) if isinstance(raw, str | bytes) else raw
|
||||
lines = [line["id"] for line in (body or {}).get("lines", [])]
|
||||
order = {line: i for i, line in enumerate(lines)}
|
||||
lines = (body or {}).get("lines", [])
|
||||
order = {line["id"]: i for i, line in enumerate(lines)}
|
||||
names = dict(zip(order, line_names(lines)))
|
||||
for row in rows:
|
||||
raw = row["route_json"]
|
||||
row["route_json"] = json.loads(raw) if isinstance(raw, str | bytes) else raw
|
||||
index = order.get(row["line_id"], len(order))
|
||||
row["line_name"] = "본선" if index == 0 else f"갈래 {index}"
|
||||
row["line_name"] = names.get(row["line_id"], "갈래")
|
||||
rows.sort(key=lambda r: (r["year"], order.get(r["line_id"], len(order)), r["from_m"]))
|
||||
return rows
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""D03 손 긋기 꼴 다루기(PLAN 562-3 · 563-1) — TS `logic/D03_PlanAnalysis_Plan.ts`.
|
||||
"""D03 손 긋기 꼴 다루기(PLAN 562-3 · 563-1 · 580) — TS `logic/D03_PlanAnalysis_Plan.ts`.
|
||||
|
||||
조각이 선을 빈틈없이 덮음 · 마크 놓기 · 지우기 · 갈래 차례 밀기 · 갈래 붙은 점 못 뺌 · 서버 꼴 읽기 ·
|
||||
화면이 보내는 꼴(선 · 마크 · 조각)을 서버 셈(`D03_PlanAnalysis_Network.derive`)이 그대로 받음.
|
||||
조각이 선을 빈틈없이 덮음 · 마크 놓기 · 지우기 · 갈래 차례 밀기 · 갈래 붙은 점 못 뺌 ·
|
||||
서버 꼴 읽기 · 화면 꼴(선 · 마크 · 조각)을 서버 셈(`derive`)이 그대로 받음 ·
|
||||
580 = 종점에서 그은 선 뒤집어 잇기 · 갈래로 붙음 · 꺾임점 옮김 · 지움(마크 비율) ·
|
||||
선 지움 · 방향 바꿈 · 선 통째 연도 · 마크 옮김 · 선 이름 거울(D04 `line_names`).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_530_3_plan_logic.py -q
|
||||
"""
|
||||
@@ -19,6 +21,7 @@ if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Network as server # noqa: E402
|
||||
from D04_PlanConfirm.D04_PlanConfirm_Repository import line_names # noqa: E402
|
||||
|
||||
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Plan.ts"
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
@@ -27,7 +30,8 @@ _RUNNER = """
|
||||
import { writeFileSync } from "node:fs";
|
||||
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
|
||||
const Y = 2026;
|
||||
let n = plan.emptyNetwork([0, 0]);
|
||||
const empty = plan.emptyNetwork();
|
||||
let n = plan.newLine(empty, [0, 0]);
|
||||
n = plan.appendVertex(n, 0, [100, 0]);
|
||||
n = plan.appendVertex(n, 0, [100, 100]);
|
||||
n = plan.normalizePieces(n, Y);
|
||||
@@ -52,10 +56,57 @@ const read = plan.fromServer(
|
||||
[{ id: "p9", line: "main", from_m: 0, to_m: 50, year: 2028 }],
|
||||
Y,
|
||||
);
|
||||
// 580 — 본선(0,0)→(100,0) 반쯤 · 종점(200,0)에서 거꾸로 그은 선이 본선 꼬리에 닿음 → 한 선
|
||||
let j = plan.normalizePieces(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), Y);
|
||||
j = plan.newLine(j, [200, 0]);
|
||||
j = plan.appendVertex(j, 1, [150, 50]);
|
||||
j = plan.appendVertex(j, 1, [100, 0]);
|
||||
j = plan.normalizePieces(j, 2027);
|
||||
const joined = plan.normalizePieces(plan.attachHead(plan.reverseLine(j, 1), 1), Y);
|
||||
const sameYear = plan.normalizePieces(
|
||||
plan.attachHead(plan.reverseLine(plan.setLineYear(j, j.lines[1].id, Y), 1), 1),
|
||||
Y,
|
||||
);
|
||||
// 종점에서 그은 선이 본선 꺾임점에 닿음 → 그 점의 갈래(선 차례 = 부모 뒤)
|
||||
let b = plan.normalizePieces(
|
||||
plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [200, 0]),
|
||||
Y,
|
||||
);
|
||||
b = plan.newLine(b, [100, 80]);
|
||||
b = plan.appendVertex(b, 1, [100, 0]);
|
||||
const asBranch = plan.attachHead(plan.reverseLine(b, 1), 1);
|
||||
// 꺾임점 옮김 · 지움 — 마크 비율 · 붙은 갈래 첫 점
|
||||
let m = plan.normalizePieces(
|
||||
plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [100, 100]),
|
||||
Y,
|
||||
);
|
||||
m = plan.normalizePieces(plan.splitPiece(m, "main", 150), Y);
|
||||
m = plan.normalizePieces(plan.appendVertex(plan.addBranch(m, 0, 1), 1, [160, 0]), Y);
|
||||
const movedV = plan.normalizePieces(plan.moveVertex(m, 0, 1, [100, -100]), Y);
|
||||
let r = plan.normalizePieces(
|
||||
plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [100, 100]),
|
||||
Y,
|
||||
);
|
||||
r = plan.normalizePieces(plan.splitPiece(r, "main", 150), Y);
|
||||
const removedV = plan.normalizePieces(plan.removeVertex(r, 0, 1), Y);
|
||||
const removeBlocked = plan.removeVertex(m, 0, 1);
|
||||
const lineBlocked = plan.removeLine(m, 0);
|
||||
const lineGone = plan.normalizePieces(plan.removeLine(m, 1), Y);
|
||||
const reversed = plan.normalizePieces(plan.reverseLine(m, 0), Y);
|
||||
const branchFlip = plan.reverseLine(m, 1);
|
||||
const lineYear = plan.setLineYear(m, "main", 2029);
|
||||
const markMoved = plan.normalizePieces(plan.moveMark(m, 1, 120), Y);
|
||||
const markClamp = plan.normalizePieces(plan.moveMark(m, 1, 500), Y);
|
||||
const names = [
|
||||
plan.lineNames([{ branch: null }]),
|
||||
plan.lineNames([{ branch: null }, { branch: null }, { branch: { line: "main", vertex: 1 } }]),
|
||||
plan.lineNames([{ branch: null }, { branch: { line: "main", vertex: 1 } }]),
|
||||
];
|
||||
writeFileSync(process.argv[2], JSON.stringify({
|
||||
one, split, tooClose, branched, inserted, blocked, dropped, guard, removed, grown, read,
|
||||
marks: plan.marksOf(branched),
|
||||
reached: plan.reachedEnd(n, [100, 100]),
|
||||
empty, joined, sameYear, asBranch, m, movedV, removedV, removeBlocked, lineBlocked, lineGone,
|
||||
reversed, branchFlip, lineYear, markMoved, markClamp, names,
|
||||
}));
|
||||
"""
|
||||
|
||||
@@ -111,7 +162,7 @@ def test_pieces_cover_and_split(tmp_path: Path) -> None:
|
||||
assert got["tooClose"] == got["split"] # 마크 1 m 안 = 안 놓음
|
||||
assert _spans(got["removed"]) == [(0, 200, 2026)] # 마크 지움 = 앞 조각 연도
|
||||
assert _spans(got["grown"]) == [(0, 50, 2026), (50, 250, 2027)] # 선이 늘면 끝 조각이 늚
|
||||
assert got["reached"] is True
|
||||
assert got["empty"] == {"lines": [], "pieces": []} # 빈 꼴 = 선 없음(시점 · 종점에서 시작)
|
||||
|
||||
|
||||
def test_branch_order_and_guard(tmp_path: Path) -> None:
|
||||
@@ -144,3 +195,71 @@ def test_server_accepts_screen_body(tmp_path: Path) -> None:
|
||||
("main", 50.0, 200.0, 2027),
|
||||
("branch-1", 0.0, 60.0, 2026),
|
||||
]
|
||||
|
||||
|
||||
def test_reversed_stroke_joins_or_branches(tmp_path: Path) -> None:
|
||||
got = _run_ts(tmp_path)
|
||||
joined = got["joined"]
|
||||
# 종점에서 그은 선 = 뒤집혀 본선 꼬리에 이어 한 선 · No.0 = 시점 쪽 · 다른 해면 이음 자리 마크
|
||||
assert len(joined["lines"]) == 1
|
||||
assert joined["lines"][0]["vertices"] == [[0, 0], [100, 0], [150, 50], [200, 0]]
|
||||
total = 100 + 2 * (50**2 * 2) ** 0.5
|
||||
spans = _spans(joined)
|
||||
assert spans[0] == (0, 100, 2026) and spans[1][0] == 100 and spans[1][2] == 2027
|
||||
assert abs(spans[1][1] - total) < 1e-3
|
||||
assert len(got["sameYear"]["pieces"]) == 1 # 같은 해면 이음 자리 마크 없음
|
||||
branch = got["asBranch"]["lines"][1]
|
||||
assert branch["branch"] == {"line": "main", "vertex": 1}
|
||||
assert branch["vertices"] == [[100, 0], [100, 80]]
|
||||
body = {**got["asBranch"], "marks": [], "pieces": [], "epsg": 5186}
|
||||
server.derive(body, 2026) # 서버가 그대로 받음(갈래 첫 점 = 부모 점)
|
||||
body = {**joined, "marks": [], "epsg": 5186}
|
||||
body["marks"] = [
|
||||
{"line": p["line"], "at_m": p["from_m"]} for p in joined["pieces"] if p["from_m"] > 0
|
||||
]
|
||||
assert len(server.derive(body, 2026)["pieces"]) == 2
|
||||
|
||||
|
||||
def test_vertex_move_remove_keeps_marks(tmp_path: Path) -> None:
|
||||
got = _run_ts(tmp_path)
|
||||
# 본선 (0,0)-(100,0)-(100,100) · 마크 150 m(둘째 구간 반) · 갈래 = 꺾임점 1 에서 (160,0)
|
||||
moved = got["movedV"]
|
||||
assert moved["lines"][0]["vertices"][1] == [100, -100]
|
||||
assert moved["lines"][1]["vertices"][0] == [100, -100] # 붙은 갈래 첫 점 같이
|
||||
first = (100**2 + 100**2) ** 0.5
|
||||
mark = [p for p in moved["pieces"] if p["line"] == "main"][1]["from_m"]
|
||||
assert abs(mark - (first + 100)) < 1e-3 # 둘째 구간(길이 200) 반
|
||||
removed = got["removedV"]
|
||||
assert removed["lines"][0]["vertices"] == [[0, 0], [100, 100]]
|
||||
share = 0.75 * (2 * 100**2) ** 0.5 # 합친 구간(200 m) 안 150 m = 3/4
|
||||
assert abs(_spans(removed)[1][0] - share) < 1e-3
|
||||
assert "갈래가 붙은 점" in got["removeBlocked"]
|
||||
assert "갈래가 붙은 선" in got["lineBlocked"]
|
||||
assert [line["id"] for line in got["lineGone"]["lines"]] == ["main"]
|
||||
assert all(p["line"] == "main" for p in got["lineGone"]["pieces"])
|
||||
assert "갈래는 방향을" in got["branchFlip"]
|
||||
|
||||
|
||||
def test_reverse_year_mark_move(tmp_path: Path) -> None:
|
||||
got = _run_ts(tmp_path)
|
||||
rev = got["reversed"]
|
||||
assert rev["lines"][0]["vertices"] == [[100, 100], [100, 0], [0, 0]]
|
||||
assert rev["lines"][1]["branch"] == {"line": "main", "vertex": 1} # 3 − 1 − 1
|
||||
assert _spans(rev) == [(0, 50, 2026), (50, 200, 2026)]
|
||||
assert {p["year"] for p in got["lineYear"]["pieces"] if p["line"] == "main"} == {2029}
|
||||
assert _spans(got["markMoved"]) == [(0, 120, 2026), (120, 200, 2026)]
|
||||
assert _spans(got["markClamp"]) == [(0, 199, 2026), (199, 200, 2026)] # 끝에서 1 m 남김
|
||||
body = {**rev, "marks": [{"line": "main", "at_m": 50}], "epsg": 5186}
|
||||
server.derive(body, 2026)
|
||||
|
||||
|
||||
def test_line_names_mirror(tmp_path: Path) -> None:
|
||||
got = _run_ts(tmp_path)
|
||||
cases = [
|
||||
[{"branch": None}],
|
||||
[{"branch": None}, {"branch": None}, {"branch": {"line": "main", "vertex": 1}}],
|
||||
[{"branch": None}, {"branch": {"line": "main", "vertex": 1}}],
|
||||
]
|
||||
assert got["names"] == [line_names(c) for c in cases]
|
||||
assert got["names"][1] == ["본선 1", "본선 2", "갈래 1"] # 시점 둘 = 둘째도 본선
|
||||
assert got["names"][2] == ["본선", "갈래 1"] # 옛 꼴 이름 그대로
|
||||
|
||||
@@ -47,7 +47,8 @@ def test_d03_parts():
|
||||
assert '"rotate-ccw", "rotate-cw"' in tools
|
||||
for b05_only in ("footprint", "station-snap", "contour-minor"):
|
||||
assert f'"{b05_only}"' not in tools, b05_only
|
||||
assert "map.stations" in draw and "map.slopes" in draw
|
||||
paint = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts") # 580 — 그림 나눔
|
||||
assert "map.stations" in paint and "map.slopes" in paint
|
||||
# 569-2 — 바탕 겹 단추(왼쪽 위 아이콘 줄) · 처음 켬 셋 · 취향 기억
|
||||
assert "createToolbar({" in layers and "createIconButton({" in layers
|
||||
assert '"aislo:pref:d03.layers"' in layers and "writeByKey(PREF_KEY" in layers
|
||||
|
||||
Reference in New Issue
Block a user