- UI_Map: 바탕(위성 · 등고선) + 겹(그리기 · 거리재기) · 도구줄 부품(되돌리기 · 다시하기 · 3D · 좌 · 우 회전 · 거리재기) · B05 createMapRotation · 왼쪽 끌기 이동 끔(가운데 단추 · 휠 방향 = 설정) - 새 UI_Draw: 시점에서 점 찍어 직선 · 미리보기 길이 · 기울기 · 제약 칸(자유 · 길이 고정 · 기울기 고정 · 둘 다 · 20 m · 9 %) · 종점 붙기 · 갈래 · 구역 마크 · Enter · Esc · Backspace - 새 UI_Measure: 두 점 길이 · 기울기 · 높이 차(지형 heightAt) - logic/Plan: 선 · 갈래 · 조각 꼴 다루기로 다시 씀(옛 갈래 넷 · 이은 누가 걷음) - UI_Page · Api: 지형 범위 카카오 지도 미리보기 → [이 범위 받기] · [범위 밖 더 받기] · GET · PUT plan/network · [초기화] · 조각 연도 고름 · 캐시 판 2 - UI_3D: 지형 + 그은 선(선 고름) · 예상 코리도 · 체크 칸 걷음 - 지움: UI_Cross · UI_Edit · logic/Expect · logic/Edit · test_544_1(예상 횡단 · 예상 코리도 셈이 화면에서 걷혀 지킬 대상 없음) - 시험: 530_3 = 손 긋기 꼴 다루기 + 서버 derive 대조로 다시 씀 · 553_4 D03 부품 단언 새 화면으로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
608 lines
22 KiB
TypeScript
608 lines
22 KiB
TypeScript
/* =============================================================================
|
|
* D03_PlanAnalysis_UI_Draw.ts
|
|
* D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 요구서 389 3장 8~10) — 시점에서 점을 찍어 직선으로 이음.
|
|
*
|
|
* 본선 = D02 시점에서 · D02 종점 둘레(SNAP_PX)에 오면 종점에 붙고 그 점에서 끝.
|
|
* 미리보기 = 마지막 점 → 마우스 길이(m) · 원지반 기울기(%, + 오르막) — 제약 칸 넷:
|
|
* 자유 · 길이 고정 · 기울기 고정(마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중 마우스에 가까운 것).
|
|
* 갈래 = 기존 선 꺾임점(선 위면 꺾임점 끼움)을 눌러 시작 · 구역 마크 = 선 위 누름(오른쪽 = 지움).
|
|
* 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기(설정 창 단축키 표 D03 줄).
|
|
* 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`) · 꼴 다루기 = `logic/D03_PlanAnalysis_Plan` ·
|
|
* 값을 바꾸면 `commit(다음 꼴)` 한 번 = 되돌리기 한 걸음(스택 · 캐시 = 페이지).
|
|
* ========================================================================== */
|
|
|
|
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,
|
|
dropLastVertex,
|
|
insertVertex,
|
|
reachedEnd,
|
|
removeMark,
|
|
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";
|
|
|
|
/** 종점 · 꺾임점에 붙는 반경(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 interface DrawPreview {
|
|
point: Vertex;
|
|
lengthM: number;
|
|
slopePct: number | null;
|
|
/** 붙음 · 자리 없음 안내(있으면 점을 못 찍음 = `blocked`). */
|
|
note: string | null;
|
|
blocked: boolean;
|
|
snapped: boolean;
|
|
}
|
|
|
|
export interface PlanDrawParams {
|
|
base: () => { start: Vertex; end: Vertex };
|
|
network: () => PlanNetwork;
|
|
heightAt: () => (x: number, y: number) => number;
|
|
commit: (next: PlanNetwork) => void;
|
|
/** 모드 · 그리는 선이 바뀜 — 페이지가 단추 · 안내를 다시. */
|
|
onMode: () => void;
|
|
}
|
|
|
|
export interface PlanDraw {
|
|
layer: PlanMapLayer;
|
|
/** 좌측 「그리기」 칸 몸(모드 단추 · 제약 칸 · 안내). */
|
|
panel: HTMLElement;
|
|
mode: () => DrawMode;
|
|
setMode: (mode: DrawMode) => void;
|
|
setEditable: (editable: boolean) => void;
|
|
/** 키 — 받았으면 참(Enter · Esc · Backspace). */
|
|
key: (event: KeyboardEvent) => boolean;
|
|
/** 되돌리기 뒤 — 그리던 선이 사라졌으면 그리기를 멈춤. */
|
|
sync: () => void;
|
|
/** 검증 창구 — 지금 미리보기 · 제약. */
|
|
preview: () => DrawPreview | 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 strokeStart: PlanNetwork | null = null; // Esc 로 돌아갈 꼴
|
|
let preview: DrawPreview | null = null;
|
|
let candidates: Vertex[] = [];
|
|
let kind: DrawConstraint = "free";
|
|
let lengthM = DRAW_DEFAULT_LENGTH_M;
|
|
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
|
|
|
|
// ── 좌측 칸 ─────────────────────────────────────────────
|
|
const mainButton = 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"),
|
|
});
|
|
const markButton = createButton({
|
|
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);
|
|
const kindSegment = createSegment({
|
|
options: [
|
|
{ value: "free", label: "자유" },
|
|
{ value: "length", label: "길이 고정" },
|
|
{ value: "slope", label: "기울기 고정" },
|
|
{ value: "both", label: "둘 다" },
|
|
],
|
|
value: kind,
|
|
size: 24,
|
|
ariaLabel: "긋기 제약",
|
|
onChange: (value) => {
|
|
kind = value as DrawConstraint;
|
|
fieldsState();
|
|
},
|
|
});
|
|
kindSegment.root.dataset.role = "draw-constraint";
|
|
const lengthField = createInputField({
|
|
label: "길이",
|
|
type: "number",
|
|
value: String(lengthM),
|
|
min: 1,
|
|
unit: "m",
|
|
size: 24,
|
|
onInput: (value) => {
|
|
const next = Number(value);
|
|
if (next > 0) lengthM = next;
|
|
lengthField.setError(next > 0 ? undefined : "0 보다 큰 수");
|
|
},
|
|
});
|
|
lengthField.input.dataset.role = "draw-length";
|
|
const slopeField = createInputField({
|
|
label: "기울기(+ 오르막)",
|
|
type: "number",
|
|
value: String(slopePct),
|
|
unit: "%",
|
|
size: 24,
|
|
onInput: (value) => {
|
|
const next = Number(value);
|
|
if (value.trim() !== "" && Number.isFinite(next)) slopePct = next;
|
|
slopeField.setError(value.trim() !== "" && Number.isFinite(next) ? undefined : "숫자");
|
|
},
|
|
});
|
|
slopeField.input.dataset.role = "draw-slope";
|
|
const fieldRow = document.createElement("div");
|
|
fieldRow.className = "d03-plan__row";
|
|
fieldRow.append(lengthField.root, slopeField.root);
|
|
const hint = createText("", "muted");
|
|
hint.classList.add("d03-plan__hint");
|
|
const panel = document.createElement("div");
|
|
panel.className = "d03-plan__draw";
|
|
panel.append(modeRow, kindSegment.root, fieldRow, hint);
|
|
|
|
function fieldsState(): void {
|
|
lengthField.input.disabled = kind === "free" || kind === "slope";
|
|
slopeField.input.disabled = kind === "free" || kind === "length";
|
|
}
|
|
|
|
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"));
|
|
markButton.setAttribute("aria-pressed", String(mode === "mark"));
|
|
panel.dataset.mode = mode;
|
|
hint.textContent = !editable
|
|
? "보기만 — 산림청 계정에서 긋습니다."
|
|
: mode === "draw"
|
|
? "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기"
|
|
: mode === "branch"
|
|
? "갈래를 시작할 선 위 점을 누르세요."
|
|
: mode === "mark"
|
|
? "선 위를 눌러 구역을 나눕니다 · 오른쪽 누름 = 마크 지움"
|
|
: done
|
|
? "본선이 종점에 닿았습니다 — 갈래 · 구역 마크 · 연도를 정하세요."
|
|
: "[본선 긋기]로 시점에서 점을 찍어 갑니다.";
|
|
}
|
|
fieldsState();
|
|
|
|
// ── 셈 ──────────────────────────────────────────────────
|
|
const lastOf = (line: number): Vertex | null => {
|
|
const vertices = params.network().lines[line]?.vertices ?? [];
|
|
return vertices[vertices.length - 1] ?? 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) : [];
|
|
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];
|
|
} else if (kind === "length") {
|
|
point = fixedLengthPoint(from, cursor, lengthM);
|
|
} else if (kind === "slope") {
|
|
point = fixedSlopePoint(from, cursor, slopePct, z);
|
|
if (!point) note = `이 방향엔 ${signed(slopePct)} % 자리 없음`;
|
|
} else if (kind === "both") {
|
|
point = compassPoint(from, cursor, lengthM, slopePct, z);
|
|
if (!point) note = `반지름 ${fmt(lengthM)} m 원 위에 ${signed(slopePct)} % 자리 없음`;
|
|
}
|
|
const at = point ?? cursor;
|
|
const length = segmentLength(from, at);
|
|
return {
|
|
point: at,
|
|
lengthM: length,
|
|
slopePct: groundSlopePct(from, at, z),
|
|
note,
|
|
blocked: !point || length <= 0,
|
|
snapped,
|
|
};
|
|
}
|
|
|
|
/** 화면 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;
|
|
const vertex = line.vertices.findIndex((v) => near(map.toScreen(v), [px, py], SNAP_PX));
|
|
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));
|
|
active = null;
|
|
strokeStart = null;
|
|
preview = null;
|
|
candidates = [];
|
|
}
|
|
|
|
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 addPoint(): boolean {
|
|
if (active === null || !preview) return false;
|
|
if (preview.blocked) {
|
|
showToast(preview.note ?? "여기에는 점을 찍을 수 없습니다.", "info");
|
|
return false;
|
|
}
|
|
const line = active;
|
|
const ended = preview.snapped;
|
|
params.commit(appendVertex(params.network(), line, preview.point));
|
|
if (ended) {
|
|
showToast("본선이 종점에 닿았습니다.", "success");
|
|
setMode("idle");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
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();
|
|
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),
|
|
},
|
|
);
|
|
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) {
|
|
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 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);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
hover(px, py, map) {
|
|
preview = mode === "draw" ? previewAt(px, py, map) : null;
|
|
},
|
|
leave() {
|
|
preview = null;
|
|
},
|
|
};
|
|
|
|
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 {
|
|
layer,
|
|
panel,
|
|
mode: () => mode,
|
|
setMode,
|
|
setEditable(next) {
|
|
editable = next;
|
|
if (!editable) setMode("idle");
|
|
panelState();
|
|
},
|
|
key(event) {
|
|
if (mode === "draw" && event.key === "Enter") {
|
|
setMode("idle");
|
|
return true;
|
|
}
|
|
if (mode !== "idle" && event.key === "Escape") {
|
|
if (mode === "draw" && strokeStart && strokeStart !== params.network())
|
|
params.commit(strokeStart);
|
|
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)
|
|
: 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");
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
sync() {
|
|
if (active !== null && active >= params.network().lines.length) {
|
|
active = null;
|
|
mode = "idle";
|
|
}
|
|
panelState();
|
|
},
|
|
preview: () => preview,
|
|
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)];
|
|
fieldsState();
|
|
},
|
|
};
|
|
}
|