Merge remote-tracking branch 'origin/dev' into sub_laptop_5
충돌 = UI_Page.ts 메뉴 블록 옆 주석(596-4) — 둘 다 살림
This commit is contained in:
@@ -3,26 +3,26 @@
|
||||
* D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 580 · 요구서 389 3장 8~10 · 440 3~7) — 점을 찍어 직선으로 이음.
|
||||
*
|
||||
* 우클릭 메뉴(591-2 · `D03_PlanAnalysis_UI_Points`) = 이 자리에서 시작 — 빈 땅도 새 본선(지점 없이 긋기).
|
||||
* [긋기] 하나 — 첫 누름 자리로 시작: 지점 = 새 본선(측점 No.0 = 긋기 시작한 지점 · 반대는 [방향 바꾸기]) ·
|
||||
* [긋기] 하나 — 첫 누름 자리로 시작: 지점 = 새 본선(측점 No.0 = 긋기 시작한 지점) ·
|
||||
* 선이 이미 놓인 지점 = 그 점의 갈래 · 선 꼬리 = 이어 긋기 · 갈래 없는 선 머리 = 뒤집어 이어 긋고 끝낼 때
|
||||
* 되돌림 · 선 위 · 꺾임점 = 갈래. 다른 지점 · 다른 선 둘레(SNAP_PX)에 오면 거기 붙고 그 점에서 끝 —
|
||||
* 머리가 다른 선 꼬리에 닿으면 한 선으로 잇고 · 꺾임점에 닿으면 그 점의 갈래.
|
||||
* 머리가 다른 선 꼬리에 닿으면 한 선으로 잇고 · 꺾임점에 닿으면 그 점의 갈래 · 본선 꼬리가 다른 구역에
|
||||
* 닿아 끝나면 저절로 그 점의 갈래(No.0 = 닿은 점 · 599 · 요구서 463 3장 10).
|
||||
* 다시 긋기(599-3 · `respan`) = 고른 구간 앞 노드부터 긋고 원래 뒤 노드에 붙으면 끝(속 구간 흐리게 ·
|
||||
* Enter 로 끝내면 취소) — 끝나면 `replaceSpan` 한 번 = 되돌리기 한 걸음.
|
||||
* 미리보기 = 마지막 점 → 마우스 길이(m) · 가상 종단 기울기(원지반 % · ° · + 오르막) — 제약 칸 넷:
|
||||
* 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중
|
||||
* 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 길이·기울기 고정(양각기 원 위 후보 중
|
||||
* 마우스에 가까운 것) · 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈).
|
||||
* 긋기는 해를 안 물음(585-3) — 새 선 = 사업연도 시작 해 · 갈래 = 붙은 자리 구간 해 · 이어 긋기 = 끝 구간이 늘어남 ·
|
||||
* 여러 해 = 다 그은 뒤 도구줄 「연도 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름.
|
||||
* 여러 해 = 다 그은 뒤 그리기 칸 「연도 구역 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름.
|
||||
* 고르기 · 고치기 · 마커(쉴 때) = `D03_PlanAnalysis_UI_Edit`.
|
||||
* 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기(설정 창 단축키 표 D03 줄).
|
||||
* 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`) · 꼴 다루기 = `logic/D03_PlanAnalysis_Plan` ·
|
||||
* 값을 바꾸면 `commit(다음 꼴)` 한 번 = 되돌리기 한 걸음(스택 · 캐시 = 페이지).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { keyIs } from "../A00_Common/key_registry";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { createSegment } from "@ui/ui_template_segment";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import {
|
||||
compassCandidates,
|
||||
compassPoint,
|
||||
@@ -41,26 +41,30 @@ import {
|
||||
removeLine,
|
||||
reverseLine,
|
||||
samePoint,
|
||||
type NodeRecord,
|
||||
type PlanNetwork,
|
||||
type Vertex,
|
||||
} from "./logic/D03_PlanAnalysis_Plan";
|
||||
import { nodeRecord, replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span";
|
||||
import {
|
||||
fmt,
|
||||
lineAt,
|
||||
paintNetwork,
|
||||
paintPreview,
|
||||
screenGap,
|
||||
slopeDeg,
|
||||
SNAP_PX,
|
||||
} from "./D03_PlanAnalysis_UI_DrawPaint";
|
||||
import {
|
||||
createDrawKindFields,
|
||||
createDrawPanel,
|
||||
DRAW_DEFAULT_LENGTH_M,
|
||||
DRAW_DEFAULT_SLOPE_PCT,
|
||||
type DrawConstraint,
|
||||
} from "./D03_PlanAnalysis_UI_DrawPanel";
|
||||
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||||
|
||||
/** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */
|
||||
export const DRAW_DEFAULT_LENGTH_M = 20;
|
||||
export const DRAW_DEFAULT_SLOPE_PCT = 9;
|
||||
const START_HINT = "지점 · 선 끝 · 선 위를 눌러 시작합니다 — 빈 땅은 우클릭 「여기서 긋기 시작」.";
|
||||
|
||||
export type DrawConstraint = "free" | "length" | "slope" | "both";
|
||||
export type DrawMode = "idle" | "draw";
|
||||
|
||||
export interface DrawPreview {
|
||||
@@ -87,7 +91,7 @@ export interface PlanDrawParams {
|
||||
|
||||
export interface PlanDraw {
|
||||
layer: PlanMapLayer;
|
||||
/** 좌측 「그리기」 칸 몸(모드 단추 · 제약 칸 · 안내). */
|
||||
/** 좌측 「그리기」 칸 몸(`D03_PlanAnalysis_UI_DrawPanel` — 긋기 · 연도 구역 마커 · 방식 칸). */
|
||||
panel: HTMLElement;
|
||||
mode: () => DrawMode;
|
||||
setMode: (mode: DrawMode) => void;
|
||||
@@ -104,6 +108,8 @@ export interface PlanDraw {
|
||||
stroke: () => { line: number; flip: boolean } | null;
|
||||
constraint: () => { kind: DrawConstraint; lengthM: number; slopePct: number };
|
||||
setConstraint: (kind: DrawConstraint, lengthM?: number, slopePct?: number) => void;
|
||||
/** 다시 긋기 시작(599-3 · 598 판 [다시 긋기]) — `lineId` 선 `fromVertex`~`toVertex` · 참 = 시작. */
|
||||
respan: (lineId: string, fromVertex: number, toVertex: number) => boolean;
|
||||
}
|
||||
|
||||
export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
@@ -117,96 +123,35 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
let kind: DrawConstraint = "free";
|
||||
let lengthM = DRAW_DEFAULT_LENGTH_M;
|
||||
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
|
||||
// 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점 · 그 기록
|
||||
let span: {
|
||||
line: string;
|
||||
from: number;
|
||||
to: number;
|
||||
through: Vertex[];
|
||||
records: NodeRecord[];
|
||||
} | null = null;
|
||||
const spanLine = (): number =>
|
||||
span ? params.network().lines.findIndex((line) => line.id === span!.line) : -1;
|
||||
|
||||
// ── 좌측 칸 ─────────────────────────────────────────────
|
||||
const drawButton = createButton({
|
||||
label: "긋기",
|
||||
variant: "filled",
|
||||
size: 28,
|
||||
onClick: () => setMode(mode === "draw" ? "idle" : "draw"),
|
||||
const kindFields = createDrawKindFields({
|
||||
value: { kind, lengthM, slopePct },
|
||||
onChange: (value) => ({ kind, lengthM, slopePct } = value),
|
||||
});
|
||||
const modeRow = document.createElement("div");
|
||||
modeRow.className = "d03-plan__row";
|
||||
modeRow.append(drawButton);
|
||||
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();
|
||||
},
|
||||
const drawPanel = createDrawPanel({
|
||||
kind: kindFields,
|
||||
onDraw: () => setMode(mode === "draw" ? "idle" : "draw"),
|
||||
});
|
||||
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);
|
||||
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, slopeDegText);
|
||||
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";
|
||||
}
|
||||
const panel = drawPanel.root;
|
||||
|
||||
function panelState(): void {
|
||||
drawButton.querySelector(".ui-btn__label")!.textContent = mode === "draw" ? "긋기 끝" : "긋기";
|
||||
drawButton.disabled = !editable;
|
||||
drawButton.setAttribute("aria-pressed", String(mode === "draw"));
|
||||
panel.dataset.mode = mode;
|
||||
hint.textContent = !editable
|
||||
? "보기만 — 산림청 계정에서 긋습니다."
|
||||
: mode === "draw"
|
||||
? active === null
|
||||
? START_HINT
|
||||
: "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기"
|
||||
: "[긋기]로 지점 · 선에서 긋습니다 · 지도 우클릭 = 긋기 시작 · 지점 놓기 · 선 누름 = 고름 · 「연도 마커」를 선 위로 끌면 구역을 나눕니다.";
|
||||
drawPanel.state({
|
||||
drawing: mode === "draw",
|
||||
editable,
|
||||
canMark: params.network().lines.some((line) => line.vertices.length > 1),
|
||||
});
|
||||
}
|
||||
fieldsState();
|
||||
|
||||
// ── 셈 ──────────────────────────────────────────────────
|
||||
const lastOf = (line: number): Vertex | null => {
|
||||
@@ -243,13 +188,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
|
||||
/** 마우스 자리 → 찍을 점 · 값 — 붙기가 제약보다 먼저. */
|
||||
function previewAt(px: number, py: number, map: ScreenMap): DrawPreview | null {
|
||||
if (active === null) return null;
|
||||
const from = lastOf(active);
|
||||
const spanVertices = span ? params.network().lines[spanLine()]?.vertices : undefined;
|
||||
if (active === null && !spanVertices) return null;
|
||||
const from = spanVertices
|
||||
? (span!.through[span!.through.length - 1] ?? spanVertices[span!.from])
|
||||
: lastOf(active!);
|
||||
if (!from) return null;
|
||||
const z = params.heightAt();
|
||||
const cursor = map.toMetric(px, py);
|
||||
candidates = kind === "both" ? compassCandidates(from, lengthM, slopePct, z) : [];
|
||||
const snap = snapAt(px, py, map);
|
||||
// 다시 긋기 = 원래 뒤 노드에만 붙음
|
||||
const end = spanVertices?.[span!.to];
|
||||
const snap = end
|
||||
? screenGap(map, end, px, py) <= SNAP_PX
|
||||
? { point: end, note: "뒤 노드에 붙음", onLine: null }
|
||||
: null
|
||||
: snapAt(px, py, map);
|
||||
let point: Vertex | null = cursor;
|
||||
let note: string | null = null;
|
||||
if (snap) {
|
||||
@@ -299,6 +253,12 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
samePoint(other.vertices[0], tail),
|
||||
);
|
||||
if (next >= 0) network = attachHead(network, next);
|
||||
else if (!entry.branch) {
|
||||
// 꼬리가 다른 구역 꺾임점에 닿음 = 그 점의 갈래(No.0 = 닿은 점)
|
||||
const turned = reverseLine(network, line);
|
||||
const joined = typeof turned === "string" ? turned : attachHead(turned, line);
|
||||
if (typeof joined !== "string" && joined !== turned) network = joined;
|
||||
}
|
||||
}
|
||||
if (network !== params.network()) params.commit(network);
|
||||
}
|
||||
@@ -307,6 +267,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
strokeStart = null;
|
||||
preview = null;
|
||||
candidates = [];
|
||||
span = null;
|
||||
}
|
||||
|
||||
function respan(lineId: string, from: number, to: number): boolean {
|
||||
if (!editable) return false;
|
||||
const index = params.network().lines.findIndex((line) => line.id === lineId);
|
||||
const blocked = respanBlocked(params.network(), index, from, to);
|
||||
if (blocked) {
|
||||
showToast(blocked, "info");
|
||||
return false;
|
||||
}
|
||||
setMode("draw");
|
||||
span = { line: lineId, from, to, through: [], records: [] };
|
||||
panelState();
|
||||
params.onMode();
|
||||
return true;
|
||||
}
|
||||
|
||||
function setMode(next: DrawMode): void {
|
||||
@@ -407,17 +383,40 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
}
|
||||
|
||||
function addPoint(): boolean {
|
||||
if (active === null || !preview) return false;
|
||||
if ((active === null && !span) || !preview) return false;
|
||||
if (preview.blocked) {
|
||||
showToast(preview.note ?? "여기에는 점을 찍을 수 없습니다.", "info");
|
||||
return false;
|
||||
}
|
||||
// 노드 기록 = 그때 방식 · 값(붙은 점 = 자유 · 599-2)
|
||||
const record = preview.snapped ? nodeRecord("free", 0, 0) : nodeRecord(kind, lengthM, slopePct);
|
||||
if (span) {
|
||||
if (!preview.snapped) {
|
||||
span.through.push(preview.point);
|
||||
span.records.push(record);
|
||||
return true;
|
||||
}
|
||||
const next = replaceSpan(
|
||||
params.network(),
|
||||
spanLine(),
|
||||
span.from,
|
||||
span.to,
|
||||
span.through,
|
||||
span.records,
|
||||
);
|
||||
if (typeof next === "string") showToast(next, "info");
|
||||
else params.commit(next);
|
||||
span = null;
|
||||
setMode("idle");
|
||||
return true;
|
||||
}
|
||||
const line = active;
|
||||
if (line === null) return false;
|
||||
const ended = preview.snapped;
|
||||
let next = params.network();
|
||||
if (preview.onLine)
|
||||
next = insertVertex(next, preview.onLine.line, preview.onLine.segment, preview.point);
|
||||
params.commit(appendVertex(next, line, preview.point));
|
||||
params.commit(appendVertex(next, line, preview.point, record));
|
||||
if (ended) {
|
||||
showToast(`${preview.note ?? "붙음"} — 긋기 끝`, "success");
|
||||
setMode("idle");
|
||||
@@ -427,23 +426,41 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
|
||||
const layer: PlanMapLayer = {
|
||||
draw(context, map) {
|
||||
const network = params.network();
|
||||
paintNetwork(context, map, network, active, params.heightAt(), () => {
|
||||
const from = active === null ? null : lastOf(active);
|
||||
if (from && preview)
|
||||
paintPreview(
|
||||
context,
|
||||
map,
|
||||
from,
|
||||
preview,
|
||||
kind === "both" ? { lengthM, candidates } : null,
|
||||
);
|
||||
});
|
||||
let network = params.network();
|
||||
let stroke = active;
|
||||
const index = spanLine();
|
||||
const dim = span && index >= 0 ? { line: index, from: span.from, to: span.to } : null;
|
||||
if (span && index >= 0) {
|
||||
// 다시 긋는 선 = 앞 노드 + 찍은 점(그리는 선 꼴)
|
||||
const head = network.lines[index].vertices[span.from];
|
||||
const temp = { id: "", vertices: [head, ...span.through], branch: null };
|
||||
network = { ...network, lines: [...network.lines, temp] };
|
||||
stroke = network.lines.length - 1;
|
||||
}
|
||||
paintNetwork(
|
||||
context,
|
||||
map,
|
||||
network,
|
||||
stroke,
|
||||
params.heightAt(),
|
||||
() => {
|
||||
const from = stroke === null ? null : network.lines[stroke]?.vertices.at(-1);
|
||||
if (from && preview)
|
||||
paintPreview(
|
||||
context,
|
||||
map,
|
||||
from,
|
||||
preview,
|
||||
kind === "both" ? { lengthM, candidates } : null,
|
||||
);
|
||||
},
|
||||
dim,
|
||||
);
|
||||
},
|
||||
press(px, py, map, button) {
|
||||
if (!editable) return false;
|
||||
if (mode === "draw" && button === 0) {
|
||||
if (active === null) {
|
||||
if (active === null && !span) {
|
||||
const started = begin(px, py, map);
|
||||
panelState();
|
||||
params.onMode();
|
||||
@@ -485,6 +502,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
},
|
||||
key(event) {
|
||||
if (mode === "draw" && keyIs(event, "plan.drawFinish")) {
|
||||
if (span) showToast("다시 긋기는 뒤 노드에 붙어야 끝 — 취소했습니다.", "info");
|
||||
setMode("idle");
|
||||
return true;
|
||||
}
|
||||
@@ -495,6 +513,11 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
setMode("idle");
|
||||
return true;
|
||||
}
|
||||
if (mode === "draw" && span && keyIs(event, "plan.drawBack")) {
|
||||
span.records.pop();
|
||||
if (!span.through.pop()) setMode("idle");
|
||||
return true;
|
||||
}
|
||||
if (mode === "draw" && active !== null && keyIs(event, "plan.drawBack")) {
|
||||
const network = params.network();
|
||||
const step =
|
||||
@@ -512,9 +535,13 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
return false;
|
||||
},
|
||||
sync() {
|
||||
if (active !== null && active >= params.network().lines.length) {
|
||||
if (
|
||||
(active !== null && active >= params.network().lines.length) ||
|
||||
(span && spanLine() < 0)
|
||||
) {
|
||||
active = null;
|
||||
flip = false;
|
||||
span = null;
|
||||
mode = "idle";
|
||||
}
|
||||
panelState();
|
||||
@@ -524,13 +551,10 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
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)];
|
||||
degText();
|
||||
}
|
||||
fieldsState();
|
||||
if (length !== undefined) lengthM = length;
|
||||
if (slope !== undefined) slopePct = slope;
|
||||
kindFields.set({ kind, lengthM, slopePct });
|
||||
},
|
||||
respan,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type HeightAt,
|
||||
} from "./logic/D03_PlanAnalysis_Draw";
|
||||
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import { nodeOff } from "./logic/D03_PlanAnalysis_Span";
|
||||
import {
|
||||
planColors,
|
||||
PLAN_STATION_INTERVAL_M,
|
||||
@@ -33,6 +34,32 @@ export const LINE_HIT_PX = 8;
|
||||
/** 구간 길이 라벨을 보일 화면 길이(px). */
|
||||
const LABEL_MIN_PX = 70;
|
||||
|
||||
/** 지도 위 그림 검은 테(600-1) — 지금 길을 검은 선 → 색 선 두 번 긋고 점선 무늬를 되돌림 · 길은 부르는 쪽이 먼저 만듦. */
|
||||
export function strokeCased(
|
||||
context: CanvasRenderingContext2D,
|
||||
color: string,
|
||||
width: number,
|
||||
dash: number[] = [],
|
||||
): void {
|
||||
context.setLineDash(dash);
|
||||
context.lineWidth = width + 3;
|
||||
context.strokeStyle = planColors().casing;
|
||||
context.stroke();
|
||||
context.lineWidth = width;
|
||||
context.strokeStyle = color;
|
||||
context.stroke();
|
||||
context.setLineDash([]);
|
||||
}
|
||||
|
||||
/** 지도 위 점 — 색 채움 + 검은 테. */
|
||||
function fillCased(context: CanvasRenderingContext2D, color: string): void {
|
||||
context.fillStyle = color;
|
||||
context.fill();
|
||||
context.lineWidth = 1.5;
|
||||
context.strokeStyle = planColors().casing;
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
export const fmt = (value: number): string => value.toFixed(1);
|
||||
const signed = (value: number): string => `${value >= 0 ? "+" : ""}${value.toFixed(1)}`;
|
||||
/** 기울기 % → 각(°) — 가상 종단 기울기를 두 단위로. */
|
||||
@@ -86,7 +113,7 @@ export function lineAt(
|
||||
}
|
||||
|
||||
/** 그은 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · (extra = 미리보기).
|
||||
* `active` = 그리는 선(굵은 보라). */
|
||||
* `active` = 그리는 선(굵은 보라) · `dim` = 다시 긋는 구간(599-3 · 흐리게). */
|
||||
export function paintNetwork(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
@@ -94,6 +121,7 @@ export function paintNetwork(
|
||||
active: number | null,
|
||||
z: HeightAt,
|
||||
extra: () => void,
|
||||
dim: { line: number; from: number; to: number } | null = null,
|
||||
): void {
|
||||
const colors = planColors();
|
||||
context.lineCap = "round";
|
||||
@@ -102,13 +130,22 @@ export function paintNetwork(
|
||||
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();
|
||||
const path = (vertices: ReadonlyArray<Vertex>): void => {
|
||||
context.beginPath();
|
||||
vertices.forEach((v, i) => {
|
||||
const [sx, sy] = map.toScreen(v);
|
||||
if (i) context.lineTo(sx, sy);
|
||||
else context.moveTo(sx, sy);
|
||||
});
|
||||
context.stroke();
|
||||
};
|
||||
if (dim?.line === index) {
|
||||
path(line.vertices.slice(0, dim.from + 1));
|
||||
path(line.vertices.slice(dim.to));
|
||||
context.globalAlpha = 0.3;
|
||||
path(line.vertices.slice(dim.from, dim.to + 1));
|
||||
context.globalAlpha = 1;
|
||||
} else path(line.vertices);
|
||||
if (map.stations)
|
||||
drawStationTicks(
|
||||
context,
|
||||
@@ -123,15 +160,22 @@ export function paintNetwork(
|
||||
);
|
||||
if (map.slopes) segmentLabels(context, map, line.vertices, z);
|
||||
});
|
||||
// 꺾임점
|
||||
// 꺾임점 · 기록과 어긋난 노드(옮김) = 경고 고리(599-2)
|
||||
context.fillStyle = "#ffffff";
|
||||
context.strokeStyle = colors.warn;
|
||||
context.lineWidth = 2;
|
||||
for (const line of network.lines)
|
||||
for (const v of line.vertices.slice(1)) {
|
||||
line.vertices.forEach((v, i) => {
|
||||
if (!i) return;
|
||||
const [sx, sy] = map.toScreen(v);
|
||||
context.beginPath();
|
||||
context.arc(sx, sy, 3, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
if (!nodeOff(line, i, z)) return;
|
||||
context.beginPath();
|
||||
context.arc(sx, sy, 7, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
});
|
||||
drawPieces(context, map, network);
|
||||
extra();
|
||||
}
|
||||
@@ -151,6 +195,9 @@ export function pointLayer(points: () => Vertex[]): PlanMapLayer {
|
||||
context.closePath();
|
||||
context.fillStyle = pointColor(i);
|
||||
context.fill();
|
||||
context.lineWidth = 5;
|
||||
context.strokeStyle = planColors().casing;
|
||||
context.stroke();
|
||||
context.lineWidth = 2;
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.stroke();
|
||||
@@ -218,6 +265,9 @@ export function paintMark(
|
||||
context.closePath();
|
||||
context.fillStyle = hollow ? "#ffffff" : color;
|
||||
context.fill();
|
||||
context.lineWidth = hollow ? 5.5 : 4.5;
|
||||
context.strokeStyle = planColors().casing;
|
||||
context.stroke();
|
||||
context.lineWidth = hollow ? 2.5 : 1.5;
|
||||
context.strokeStyle = hollow ? color : "#ffffff";
|
||||
context.stroke();
|
||||
@@ -235,31 +285,24 @@ export function paintPreview(
|
||||
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;
|
||||
strokeCased(context, colors.active, 1.5, [3, 4]);
|
||||
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.arc(cx, cy, 3.5, 0, Math.PI * 2);
|
||||
fillCased(context, colors.active);
|
||||
}
|
||||
}
|
||||
context.strokeStyle = preview.blocked ? colors.warn : colors.active;
|
||||
context.lineWidth = 2;
|
||||
context.setLineDash([6, 4]);
|
||||
const color = preview.blocked ? colors.warn : colors.active;
|
||||
context.beginPath();
|
||||
context.moveTo(ax, ay);
|
||||
context.lineTo(bx, by);
|
||||
context.stroke();
|
||||
context.setLineDash([]);
|
||||
strokeCased(context, color, 2.5, [6, 4]);
|
||||
context.beginPath();
|
||||
context.arc(bx, by, 5, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
strokeCased(context, color, 2.5);
|
||||
const value =
|
||||
`${fmt(preview.lengthM)} m · ` +
|
||||
(preview.slopePct === null ? "지형 밖" : slopeText(preview.slopePct));
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_DrawPanel.ts
|
||||
* D03 좌측 「그리기」 칸 몸(PLAN 599-1 · 599-4 · 요구 463-10 · 22) — [긋기] · 연도 구역 마커 · 긋기 방식 칸.
|
||||
*
|
||||
* 방식 칸 `createDrawKindFields` = 마디 고름 2 × 2(자유 · 길이 고정 · 기울기 고정 · 길이·기울기 고정) +
|
||||
* 길이 · 기울기 두 열(기울기 아래 ≈ °) — 그리기 칸 · 노드 판(598-4)이 같은 부품을 씀.
|
||||
* 연도 구역 마커 = 끌어 선 위에 놓기(`YEAR_MARK_DRAG` · 받음 = `D03_PlanAnalysis_UI_Edit` drop).
|
||||
* 설명 글 없음(요구 463-6) — 칸 오류 · ≈ ° 값만.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { UiSize } from "@ui/ui_template_button";
|
||||
import { createButton } 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 { fmt, slopeDeg } from "./D03_PlanAnalysis_UI_DrawPaint";
|
||||
import { YEAR_MARK_DRAG } from "./D03_PlanAnalysis_UI_Map";
|
||||
|
||||
/** 제약 칸 처음 값 — 길이 = 측점 간격(지식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 interface DrawKindValue {
|
||||
kind: DrawConstraint;
|
||||
lengthM: number;
|
||||
slopePct: number;
|
||||
}
|
||||
|
||||
export const DRAW_KIND_LABELS: Record<DrawConstraint, string> = {
|
||||
free: "자유",
|
||||
length: "길이 고정",
|
||||
slope: "기울기 고정",
|
||||
both: "길이·기울기 고정",
|
||||
};
|
||||
|
||||
export interface DrawKindFields {
|
||||
root: HTMLElement;
|
||||
get: () => DrawKindValue;
|
||||
/** 값을 바꿈(onChange 는 안 부름). */
|
||||
set: (value: Partial<DrawKindValue>) => void;
|
||||
setDisabled: (disabled: boolean) => void;
|
||||
}
|
||||
|
||||
/** 긋기 방식 칸 — 고름 · 길이 · 기울기가 바뀔 때마다 `onChange(지금 값)`(빈 · 틀린 수는 칸 오류만). */
|
||||
export function createDrawKindFields(opts: {
|
||||
value: DrawKindValue;
|
||||
onChange: (value: DrawKindValue) => void;
|
||||
size?: UiSize;
|
||||
}): DrawKindFields {
|
||||
const size = opts.size ?? 24;
|
||||
let value = { ...opts.value };
|
||||
let disabled = false;
|
||||
const changed = (): void => opts.onChange({ ...value });
|
||||
const segment = createSegment({
|
||||
options: (Object.keys(DRAW_KIND_LABELS) as DrawConstraint[]).map((kind) => ({
|
||||
value: kind,
|
||||
label: DRAW_KIND_LABELS[kind],
|
||||
})),
|
||||
value: value.kind,
|
||||
size,
|
||||
columns: 2,
|
||||
ariaLabel: "긋기 방식",
|
||||
onChange: (next) => {
|
||||
value.kind = next as DrawConstraint;
|
||||
fieldsState();
|
||||
changed();
|
||||
},
|
||||
});
|
||||
segment.root.dataset.role = "draw-constraint";
|
||||
const lengthField = createInputField({
|
||||
label: "길이",
|
||||
type: "number",
|
||||
value: String(value.lengthM),
|
||||
min: 1,
|
||||
unit: "m",
|
||||
size,
|
||||
onInput: (text) => {
|
||||
const next = Number(text);
|
||||
lengthField.setError(next > 0 ? undefined : "0 보다 큰 수");
|
||||
if (!(next > 0)) return;
|
||||
value.lengthM = next;
|
||||
changed();
|
||||
},
|
||||
});
|
||||
lengthField.input.dataset.role = "draw-length";
|
||||
const slopeField = createInputField({
|
||||
label: "기울기",
|
||||
type: "number",
|
||||
value: String(value.slopePct),
|
||||
unit: "%",
|
||||
size,
|
||||
onInput: (text) => {
|
||||
const next = Number(text);
|
||||
const ok = text.trim() !== "" && Number.isFinite(next);
|
||||
slopeField.setError(ok ? undefined : "숫자");
|
||||
if (!ok) return;
|
||||
value.slopePct = next;
|
||||
degText();
|
||||
changed();
|
||||
},
|
||||
});
|
||||
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(value.slopePct)))}°`;
|
||||
};
|
||||
const slopeColumn = document.createElement("div");
|
||||
slopeColumn.append(slopeField.root, slopeDegText);
|
||||
const fields = document.createElement("div");
|
||||
fields.className = "d03-plan__draw-fields";
|
||||
fields.append(lengthField.root, slopeColumn);
|
||||
const root = document.createElement("div");
|
||||
root.className = "d03-plan__draw-kind";
|
||||
root.append(segment.root, fields);
|
||||
|
||||
function fieldsState(): void {
|
||||
for (const button of segment.root.querySelectorAll("button")) button.disabled = disabled;
|
||||
lengthField.input.disabled = disabled || value.kind === "free" || value.kind === "slope";
|
||||
slopeField.input.disabled = disabled || value.kind === "free" || value.kind === "length";
|
||||
}
|
||||
degText();
|
||||
fieldsState();
|
||||
|
||||
return {
|
||||
root,
|
||||
get: () => ({ ...value }),
|
||||
set(next) {
|
||||
value = { ...value, ...next };
|
||||
segment.set(value.kind);
|
||||
lengthField.input.value = String(value.lengthM);
|
||||
slopeField.input.value = String(value.slopePct);
|
||||
lengthField.setError(undefined);
|
||||
slopeField.setError(undefined);
|
||||
degText();
|
||||
fieldsState();
|
||||
},
|
||||
setDisabled(next) {
|
||||
disabled = next;
|
||||
fieldsState();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface DrawPanel {
|
||||
root: HTMLElement;
|
||||
/** 단추 글 · 막힘 — 긋는 중 · 고칠 수 있음 · 마커 놓을 선 있음. */
|
||||
state: (state: { drawing: boolean; editable: boolean; canMark: boolean }) => void;
|
||||
}
|
||||
|
||||
/** 좌측 「그리기」 칸 — [긋기] · 연도 구역 마커(끌기) · 방식 칸. */
|
||||
export function createDrawPanel(opts: { kind: DrawKindFields; onDraw: () => void }): DrawPanel {
|
||||
const drawButton = createButton({
|
||||
label: "긋기",
|
||||
variant: "filled",
|
||||
size: 28,
|
||||
onClick: opts.onDraw,
|
||||
});
|
||||
const markButton = createButton({
|
||||
label: "연도 구역 마커",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
iconStart: "flag",
|
||||
});
|
||||
markButton.title = "선 위로 끌어 놓으면 구역을 나눕니다";
|
||||
markButton.dataset.role = "draw-year-mark";
|
||||
markButton.draggable = true;
|
||||
markButton.addEventListener("dragstart", (event) => {
|
||||
event.dataTransfer?.setData(YEAR_MARK_DRAG, "1");
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy";
|
||||
});
|
||||
const row = document.createElement("div");
|
||||
row.className = "d03-plan__row";
|
||||
row.append(drawButton, markButton);
|
||||
const root = document.createElement("div");
|
||||
root.className = "d03-plan__draw";
|
||||
root.append(row, opts.kind.root);
|
||||
return {
|
||||
root,
|
||||
state({ drawing, editable, canMark }) {
|
||||
drawButton.querySelector(".ui-btn__label")!.textContent = drawing ? "긋기 끝" : "긋기";
|
||||
drawButton.disabled = !editable;
|
||||
drawButton.setAttribute("aria-pressed", String(drawing));
|
||||
markButton.disabled = !editable || drawing || !canMark;
|
||||
root.dataset.mode = drawing ? "draw" : "idle";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export const GROUPS: readonly (readonly LayerDef[])[] = [
|
||||
{
|
||||
key: "forestRoadFile",
|
||||
icon: "map",
|
||||
title: "올린 임도망도(산림청 시군구 SHP = 진한 주황 실선 · 옆 단추로 올림)",
|
||||
title: "올린 임도망도(산림청 시군구 SHP = 진한 주황 실선 · 왼쪽 「위치」 칸에서 올림)",
|
||||
on: false,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
*
|
||||
* 겹 = 바탕(위성 · 음영기복 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 급경사 색) ·
|
||||
* 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 기존 임도 짐작(소로 · 미분류) ·
|
||||
* 올린 임도망도(시군구 SHP · 옆 올리기 단추)) ·
|
||||
* 올린 임도망도(시군구 SHP · 올리기 = 왼쪽 「위치」 칸 `uploadRoads`)) ·
|
||||
* 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 산사태위험지도 · 산사태취약지역(시도 표 점 · 지번 필지 테두리) ·
|
||||
* 선 겹(단층 · 지질구조선 · 0차곡) ·
|
||||
* 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
|
||||
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
|
||||
* 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음 · 받기 실패 = 그 겹만 꺼짐 + 알림.
|
||||
* 받기 = 켤 때 · 받은 지형 범위(`have`)가 바뀌면 다시(596-4 · 등고 · 세류 주소에 범위 지문) · 지형을 안 받았으면 단추를 막음 ·
|
||||
* 받기 실패 = 그 겹만 꺼짐 + 알림.
|
||||
* 그리기 차례(아래 → 위) = 위성(GL 조각만 · 옛 캔버스 = 위성 없음 · 590-2) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 →
|
||||
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
|
||||
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
|
||||
@@ -22,11 +23,11 @@
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { createLegend } from "@ui/ui_template_flow_legend";
|
||||
import { pickFiles } from "@ui/ui_template_drop";
|
||||
import { createIconButton } from "@ui/ui_template_button";
|
||||
import { createMapTools } from "@ui/ui_template_map_tools";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { fetchCachedJson } from "../A00_Common/b_asset_cache";
|
||||
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
||||
import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
fetchPlanZeroOrder,
|
||||
fetchPlanZones,
|
||||
uploadPlanForestRoads,
|
||||
type PlanForestRoads,
|
||||
} from "./D03_PlanAnalysis_Api";
|
||||
import {
|
||||
FOREST_ROAD_KINDS,
|
||||
@@ -81,7 +83,7 @@ const glFont = (weight: string, text: string): Promise<unknown> =>
|
||||
const CANVAS_PREF_KEY = "aislo:pref:d03.canvas";
|
||||
/** 옛 캔버스 알림 — 위성 = GL 조각만(590-2). */
|
||||
const NO_SATELLITE_NOTE = "옛 그림은 위성 바탕 없이 그림";
|
||||
/** 등고선을 받아 올 그은 선 둘레(m). */
|
||||
/** 등고선을 받아 올 그은 선 둘레(m) — 받은 범위를 모를 때만. */
|
||||
const CONTOUR_PAD_M = 600;
|
||||
type DataKey = keyof Prepared;
|
||||
|
||||
@@ -94,8 +96,10 @@ export interface PlanLayers {
|
||||
legend: HTMLElement;
|
||||
/** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */
|
||||
layers: PlanMapLayer[];
|
||||
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤. */
|
||||
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤 · 받은 범위가 바뀌면 겹을 새로 받음. */
|
||||
load: () => Promise<void>;
|
||||
/** 임도망도 올리기(왼쪽 「위치」 칸) — 받으면 그 겹을 켬 · 못 받으면 알림 + null. */
|
||||
uploadRoads: (files: File[]) => Promise<PlanForestRoads | null>;
|
||||
/** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */
|
||||
state: () => {
|
||||
on: Record<PlanLayerKey, boolean>;
|
||||
@@ -109,15 +113,17 @@ export interface PlanLayers {
|
||||
export function createPlanLayers(opts: {
|
||||
projectId: string;
|
||||
map: PlanMap;
|
||||
/** 등고선 받을 범위를 정할 점(시점 · 종점 · 그은 선). */
|
||||
/** 등고선 받을 범위를 정할 점(시점 · 종점 · 그은 선) — 받은 범위를 모를 때만. */
|
||||
points: () => Vertex[];
|
||||
/** 받은 지형 범위(작업 좌표계 m) — 바뀜 판정 · 등고 · 세류 주소 지문. */
|
||||
have: () => [number, number, number, number] | null;
|
||||
}): PlanLayers {
|
||||
const { projectId, map } = opts;
|
||||
const on = readPref(PREF_KEY);
|
||||
const data: Partial<Prepared> = {};
|
||||
const pending = new Map<DataKey | "satellite", Promise<void>>();
|
||||
let uploading = false;
|
||||
let meta: VWorldMeta | null = null;
|
||||
let loadedHave = "";
|
||||
/** 음영 GL 그림 — 그대로 · 위성 위 그늘(「곱하기」 꼴). */
|
||||
let hillshade: { plain: GlImage; shade: GlImage } | null = null;
|
||||
const gl = createGlBase(projectId, (why) => {
|
||||
@@ -142,19 +148,6 @@ export function createPlanLayers(opts: {
|
||||
return button;
|
||||
}),
|
||||
);
|
||||
const upload = createIconButton({
|
||||
icon: "upload",
|
||||
title: "임도망도 SHP 올리기(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)",
|
||||
size: 32,
|
||||
onClick: () =>
|
||||
pickFiles(
|
||||
{ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true },
|
||||
(files) => void uploadRoads(files),
|
||||
),
|
||||
});
|
||||
upload.dataset.layer = "forestRoadUpload";
|
||||
const roadGroup = groups.find((group) => group.includes(buttons.get("forestRoadFile")!))!;
|
||||
roadGroup.splice(roadGroup.indexOf(buttons.get("forestRoadFile")!) + 1, 0, upload);
|
||||
const glButton = createIconButton({
|
||||
icon: "gauge",
|
||||
title: "그래픽 카드 그림(바탕 겹 · 글을 GPU 로 · 끔 = 옛 캔버스 그림)",
|
||||
@@ -203,7 +196,6 @@ export function createPlanLayers(opts: {
|
||||
button.title = title;
|
||||
button.setAttribute("aria-label", title);
|
||||
}
|
||||
upload.disabled = !meta || uploading;
|
||||
for (const def of LAYERS) gl.setVisible(def.key, on[def.key]);
|
||||
gl.setData("hillshade", hillshade && (on.satellite ? hillshade.shade : hillshade.plain));
|
||||
const rows = meta ? legendRows(on, data) : [];
|
||||
@@ -249,16 +241,13 @@ export function createPlanLayers(opts: {
|
||||
return job;
|
||||
}
|
||||
|
||||
/** 올리기 — 받으면 그 겹을 켜고 바로 그림 · 못 받으면 옛 것 그대로 + 서버 까닭 알림. */
|
||||
async function uploadRoads(files: File[]): Promise<void> {
|
||||
if (!meta) return;
|
||||
/** 올리기 — 받으면 그 겹을 켜고 바로 그림(지형 받기 전이면 받은 뒤 그림) · 못 받으면 옛 것 그대로 + 서버 까닭 알림. */
|
||||
async function uploadRoads(files: File[]): Promise<PlanForestRoads | null> {
|
||||
const at = meta;
|
||||
const job = uploadPlanForestRoads(projectId, files).then((info) => {
|
||||
if (meta !== at) return;
|
||||
data.forestRoadFile = {
|
||||
layer: prepareLayer(info.collection, createNormalizer(at)),
|
||||
info,
|
||||
};
|
||||
if (meta !== at) return info;
|
||||
if (at)
|
||||
data.forestRoadFile = { layer: prepareLayer(info.collection, createNormalizer(at)), info };
|
||||
gl.setData("forestRoadFile", info.collection);
|
||||
on.forestRoadFile = true;
|
||||
writeByKey(PREF_KEY, JSON.stringify(on));
|
||||
@@ -266,25 +255,35 @@ export function createPlanLayers(opts: {
|
||||
`임도망도 받음 — 범위 안 ${info.count}가닥 ${(info.length_m / 1000).toFixed(2)} km (파일 ${info.total}가닥)`,
|
||||
"success",
|
||||
);
|
||||
return info;
|
||||
});
|
||||
uploading = true;
|
||||
pending.set("forestRoadFile", job);
|
||||
sync();
|
||||
pending.set(
|
||||
"forestRoadFile",
|
||||
job.then(() => undefined),
|
||||
);
|
||||
try {
|
||||
await job;
|
||||
return await job;
|
||||
} catch (error) {
|
||||
showToast(
|
||||
`임도망도 올리지 못함 — ${error instanceof Error ? error.message : String(error)}`,
|
||||
"error",
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
uploading = false;
|
||||
pending.delete("forestRoadFile");
|
||||
sync();
|
||||
map.redrawBase();
|
||||
}
|
||||
}
|
||||
|
||||
/** 받은 범위 지문 — 등고 · 세류 주소에 붙여 브라우저 보관함이 옛 범위를 안 줌. */
|
||||
const haveTag = (): string => (opts.have() ?? []).map((v) => Math.round(v)).join("_");
|
||||
const sheetLayer = (layer: string, extra = ""): Promise<GeoJsonCollection> =>
|
||||
fetchCachedJson<GeoJsonCollection>(
|
||||
projectId,
|
||||
`${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}${extra}&v=${haveTag()}`,
|
||||
);
|
||||
|
||||
async function fetchLayer(key: PlanLayerKey, at: VWorldMeta): Promise<void> {
|
||||
const normalizer = createNormalizer(at);
|
||||
const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer =>
|
||||
@@ -297,26 +296,26 @@ export function createPlanLayers(opts: {
|
||||
features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})),
|
||||
});
|
||||
if (key === "contour") {
|
||||
// 받은 범위 통째(모르면 그은 선 둘레) — 범위가 늘면 `load` 가 다시 받게 함
|
||||
const points = opts.points();
|
||||
const xs = points.map(([x]) => x);
|
||||
const ys = points.map(([, y]) => y);
|
||||
const bbox = [
|
||||
Math.min(...xs) - CONTOUR_PAD_M,
|
||||
Math.min(...ys) - CONTOUR_PAD_M,
|
||||
Math.max(...xs) + CONTOUR_PAD_M,
|
||||
Math.max(...ys) + CONTOUR_PAD_M,
|
||||
].map((v) => v.toFixed(0));
|
||||
const bbox = (
|
||||
opts.have() ?? [
|
||||
Math.min(...xs) - CONTOUR_PAD_M,
|
||||
Math.min(...ys) - CONTOUR_PAD_M,
|
||||
Math.max(...xs) + CONTOUR_PAD_M,
|
||||
Math.max(...ys) + CONTOUR_PAD_M,
|
||||
]
|
||||
).map((v) => v.toFixed(0));
|
||||
const [collection] = await Promise.all([
|
||||
fetchCachedSheetLayer<GeoJsonCollection>(projectId, "도엽_등고선", "route", bbox.join(",")),
|
||||
sheetLayer("도엽_등고선", `&scope=route&bbox=${bbox.join(",")}`),
|
||||
glFont("400", "0123456789-"),
|
||||
]);
|
||||
data.contour = indexLayerLod(prepare(collection, ["등고수치"]));
|
||||
gl.setData("contour", collection);
|
||||
} else if (key === "stream") {
|
||||
const collection = await fetchCachedSheetLayer<GeoJsonCollection>(
|
||||
projectId,
|
||||
"도엽_하천중심선",
|
||||
);
|
||||
const collection = await sheetLayer("도엽_하천중심선");
|
||||
data.stream = [
|
||||
prepare(pick(collection, (props) => props["구분"] === "세류")),
|
||||
prepare(pick(collection, (props) => props["구분"] !== "세류")),
|
||||
@@ -491,10 +490,13 @@ export function createPlanLayers(opts: {
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const next = await fetchVWorldMeta(projectId, "satellite").catch(() => null);
|
||||
// 바뀜 = 위성 틀 또는 받은 범위(같은 도엽 안에서 늘어도 겹을 새로 · 596-4)
|
||||
const have = JSON.stringify(opts.have());
|
||||
const changed =
|
||||
have !== loadedHave ||
|
||||
JSON.stringify(next && [next.x_min, next.x_max, next.y_min, next.y_max]) !==
|
||||
JSON.stringify(meta && [meta.x_min, meta.x_max, meta.y_min, meta.y_max]);
|
||||
meta = next;
|
||||
JSON.stringify(meta && [meta.x_min, meta.x_max, meta.y_min, meta.y_max]);
|
||||
[meta, loadedHave] = [next, have];
|
||||
if (changed) {
|
||||
for (const key of Object.keys(data)) delete data[key as DataKey];
|
||||
hillshade = null;
|
||||
@@ -511,6 +513,7 @@ export function createPlanLayers(opts: {
|
||||
legend,
|
||||
layers,
|
||||
load,
|
||||
uploadRoads,
|
||||
state: () => ({
|
||||
on: { ...on },
|
||||
loaded: LAYERS.map((def) => def.key).filter(loadedOf),
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { routeColor, themeColor } from "@ui/ui_template_palette";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
computeMapRect,
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
createNormalizer,
|
||||
lonLatToScreen,
|
||||
normalizedToScreen,
|
||||
routeLineColor,
|
||||
type ViewState,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import { bindRouteEditNavigation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Input";
|
||||
@@ -36,17 +35,27 @@ import type { GlBase } from "./D03_PlanAnalysis_UI_MapGl";
|
||||
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import { localTmFrame, tmParams } from "./logic/D03_PlanAnalysis_Tm";
|
||||
|
||||
/** 도구줄 「연도 마커」 끌어 놓기 자료 꼴(585-3). */
|
||||
/** 「연도 구역 마커」 끌어 놓기 자료 꼴(585-3 · 단추 = 그리기 칸 599-4). */
|
||||
export const YEAR_MARK_DRAG = "application/x-aislo-year-mark";
|
||||
|
||||
/** 측점 간격(m) — B 규칙 측점과 같음. */
|
||||
export const PLAN_STATION_INTERVAL_M = 20;
|
||||
/** 겹 그림 색(그림 전용 값 `--draw-*` · 밝음 · 어두움 같음) — 선 · 그리는 중 · 경고 · 글 테두리. */
|
||||
export function planColors(): { line: string; active: string; warn: string; halo: string } {
|
||||
/** 지도 위 그림 색(600-1) — 지도 전용 값 `--draw-map-*`(테마 안 따름 · 바탕 늘 어두움) + 검은 테 `casing`.
|
||||
* `line` = 첫 선 색 · 선마다 차례 색 = `routeColor(index)`. */
|
||||
export function planColors(): {
|
||||
line: string;
|
||||
active: string;
|
||||
warn: string;
|
||||
measure: string;
|
||||
casing: string;
|
||||
halo: string;
|
||||
} {
|
||||
return {
|
||||
line: routeLineColor(),
|
||||
active: themeColor("--draw-primary", "#6d28d9"),
|
||||
warn: themeColor("--draw-danger", "#dc2626"),
|
||||
line: routeColor(0),
|
||||
active: themeColor("--draw-map-sketch", "#ffe94d"),
|
||||
warn: themeColor("--draw-map-warn", "#ff5a4d"),
|
||||
measure: themeColor("--draw-map-measure", "#ffffff"),
|
||||
casing: "rgba(0, 0, 0, 0.85)",
|
||||
halo: "rgba(0, 0, 0, 0.65)",
|
||||
};
|
||||
}
|
||||
@@ -115,7 +124,7 @@ export interface PlanTools {
|
||||
view3d: boolean;
|
||||
/** 거리재기 켜짐(눌림). */
|
||||
measure: boolean;
|
||||
/** 「연도 마커」 끌 수 있음(고칠 수 있고 선 있음). */
|
||||
/** 받기만 — 연도 구역 마커 단추는 그리기 칸(599-4 · `D03_PlanAnalysis_UI_DrawPanel`)이 켬끔. */
|
||||
yearMark: boolean;
|
||||
}
|
||||
|
||||
@@ -188,12 +197,6 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
const redoButton = mapToolButton("redo", { onClick: events.onRedo });
|
||||
const viewButton = mapToolButton("view3d", { onClick: events.on3d });
|
||||
const measureButton = mapToolButton("measure", { onClick: events.onMeasure });
|
||||
const yearMarkButton = mapToolButton("year-mark", { disabled: true });
|
||||
yearMarkButton.draggable = true;
|
||||
yearMarkButton.addEventListener("dragstart", (event) => {
|
||||
event.dataTransfer?.setData(YEAR_MARK_DRAG, "1");
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy";
|
||||
});
|
||||
const tools = createMapTools({
|
||||
label: "평면 도구",
|
||||
hint: {
|
||||
@@ -207,7 +210,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
"Shift+누름 = 구간 더함 · 뺌",
|
||||
"빈 곳 오른쪽으로 끌기 = 창 안 구간 · 왼쪽으로 = 걸친 구간까지",
|
||||
"고른 선 위 끌기 = 꺾임점 더해 옮김",
|
||||
"「연도 마커」 선 위로 끌어 놓기 = 구역 나눔",
|
||||
"그리기 칸 「연도 구역 마커」 선 위로 끌어 놓기 = 구역 나눔",
|
||||
"꺾임점 · 마크 끌기 = 옮김 · 마크를 선 밖으로 = 지움(앞 구간과 합침)",
|
||||
"오른쪽 누름 = 꺾임점 · 마크 지움",
|
||||
"Delete = 고른 선 · 마크 지움",
|
||||
@@ -220,7 +223,6 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
[undoButton, redoButton],
|
||||
[viewButton, "rotate-ccw", "rotate-cw"],
|
||||
[markButton("stations", "측점"), markButton("slopes", "구간 길이 · 기울기"), measureButton],
|
||||
[yearMarkButton],
|
||||
],
|
||||
});
|
||||
const toolbar = tools.root;
|
||||
@@ -517,7 +519,6 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
if (tools.view3d !== undefined) viewButton.disabled = !tools.view3d;
|
||||
if (tools.measure !== undefined)
|
||||
measureButton.setAttribute("aria-pressed", String(tools.measure));
|
||||
if (tools.yearMark !== undefined) yearMarkButton.disabled = !tools.yearMark;
|
||||
},
|
||||
redraw: drawTop,
|
||||
redrawBase: draw,
|
||||
|
||||
@@ -2,67 +2,113 @@
|
||||
* D03_PlanAnalysis_UI_Measure.ts
|
||||
* D03 거리재기 겹(PLAN 562-3) — 두 점을 누르면 그 사이 길이(m) · 원지반 기울기(%) · 높이 차(m).
|
||||
* 셋째 누름 = 새로 잼 · Esc = 지움. 지반고 = 받은 지형 `heightAt` · 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`).
|
||||
* 600-2 — 첫 누름이 노선 위면 「노선 따라」: 둘째 점 = 이어진 노선 위 가장 가까운 자리 · 길이 = 노선 따라
|
||||
* (`logic/D03_PlanAnalysis_Measure`) · 첫 누름이 선 밖이면 곧은 거리 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { groundSlopePct, segmentLength } from "./logic/D03_PlanAnalysis_Draw";
|
||||
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import {
|
||||
nearestOnRoute,
|
||||
routeSpan,
|
||||
type RoutePos,
|
||||
type RouteSpan,
|
||||
} from "./logic/D03_PlanAnalysis_Measure";
|
||||
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||||
import { lineAt, strokeCased } from "./D03_PlanAnalysis_UI_DrawPaint";
|
||||
|
||||
export interface PlanMeasure {
|
||||
layer: PlanMapLayer;
|
||||
/** 지움(Esc · 끔). */
|
||||
clear: () => void;
|
||||
/** 검증 창구 — 잰 값(두 점이 다 있으면). */
|
||||
result: () => { lengthM: number; slopePct: number | null; riseM: number | null } | null;
|
||||
/** 검증 창구 — 잰 값(두 점이 다 있으면) · `alongRoute` = 노선 따라 쟀나. */
|
||||
result: () => {
|
||||
lengthM: number;
|
||||
slopePct: number | null;
|
||||
riseM: number | null;
|
||||
alongRoute: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const fmt = (value: number, digits = 1): string => value.toFixed(digits);
|
||||
const signed = (value: number, digits = 1): string =>
|
||||
`${value >= 0 ? "+" : ""}${fmt(value, digits)}`;
|
||||
|
||||
export function createPlanMeasure(heightAt: () => (x: number, y: number) => number): PlanMeasure {
|
||||
export function createPlanMeasure(
|
||||
heightAt: () => (x: number, y: number) => number,
|
||||
network: () => PlanNetwork,
|
||||
): PlanMeasure {
|
||||
let from: Vertex | null = null;
|
||||
let to: Vertex | null = null;
|
||||
let fixed = false;
|
||||
/** 첫 누름이 노선 위였던 자리 · 둘째 점이 노선 위에 놓인 길. */
|
||||
let fromPos: RoutePos | null = null;
|
||||
let span: RouteSpan | null = null;
|
||||
|
||||
const measure = (a: Vertex, b: Vertex) => {
|
||||
const z = heightAt();
|
||||
const rise = z(b[0], b[1]) - z(a[0], a[1]);
|
||||
const lengthM = span ? span.lengthM : segmentLength(a, b);
|
||||
return {
|
||||
lengthM: segmentLength(a, b),
|
||||
slopePct: groundSlopePct(a, b, z),
|
||||
lengthM,
|
||||
slopePct: span
|
||||
? Number.isFinite(rise) && lengthM > 0
|
||||
? (rise / lengthM) * 100
|
||||
: null
|
||||
: groundSlopePct(a, b, z),
|
||||
riseM: Number.isFinite(rise) ? rise : null,
|
||||
alongRoute: !!span,
|
||||
};
|
||||
};
|
||||
|
||||
/** 둘째 점 놓기 — 노선 위 첫 점이면 이어진 노선 위 가까운 자리 · 아니면 누른 자리. */
|
||||
const place = (point: Vertex): void => {
|
||||
const near = fromPos ? nearestOnRoute(network(), point, fromPos) : null;
|
||||
span = near && fromPos ? routeSpan(network(), fromPos, near) : null;
|
||||
to = near && span ? near.point : point;
|
||||
};
|
||||
|
||||
const layer: PlanMapLayer = {
|
||||
draw(context, map: ScreenMap) {
|
||||
if (!from) return;
|
||||
const colors = planColors();
|
||||
const [ax, ay] = map.toScreen(from);
|
||||
context.lineWidth = 2;
|
||||
context.strokeStyle = colors.active;
|
||||
context.fillStyle = colors.active;
|
||||
context.setLineDash([6, 4]);
|
||||
const dot = (x: number, y: number): void => {
|
||||
context.beginPath();
|
||||
context.arc(x, y, 4.5, 0, Math.PI * 2);
|
||||
context.fillStyle = colors.measure;
|
||||
context.fill();
|
||||
context.lineWidth = 1.5;
|
||||
context.strokeStyle = colors.casing;
|
||||
context.stroke();
|
||||
};
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
if (to) {
|
||||
const [bx, by] = map.toScreen(to);
|
||||
context.beginPath();
|
||||
context.moveTo(ax, ay);
|
||||
context.lineTo(bx, by);
|
||||
context.stroke();
|
||||
context.setLineDash([]);
|
||||
for (const [x, y] of [
|
||||
[ax, ay],
|
||||
[bx, by],
|
||||
]) {
|
||||
context.beginPath();
|
||||
context.arc(x, y, 4, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
if (span) {
|
||||
span.path.forEach((point, i) => {
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
if (i) context.lineTo(sx, sy);
|
||||
else context.moveTo(sx, sy);
|
||||
});
|
||||
strokeCased(context, colors.measure, 4);
|
||||
} else {
|
||||
context.moveTo(ax, ay);
|
||||
context.lineTo(bx, by);
|
||||
strokeCased(context, colors.measure, 2.5, [6, 4]);
|
||||
}
|
||||
dot(ax, ay);
|
||||
dot(bx, by);
|
||||
const { lengthM, slopePct, riseM } = measure(from, to);
|
||||
const text =
|
||||
`${fmt(lengthM)} m · ` +
|
||||
(slopePct === null ? "지형 밖" : `${signed(slopePct)} % · Δh ${signed(riseM ?? 0)} m`);
|
||||
const rise = riseM === null ? "지형 밖" : `Δh ${signed(riseM)} m`;
|
||||
const text = span
|
||||
? `노선 따라 ${fmt(lengthM)} m · ` +
|
||||
(span.stations ? `측점 ${fmt(span.stations[0])} ~ ${fmt(span.stations[1])} m · ` : "") +
|
||||
rise
|
||||
: `${fmt(lengthM)} m · ` +
|
||||
(slopePct === null ? "지형 밖" : `${signed(slopePct)} % · ${rise}`);
|
||||
const [mx, my] = [(ax + bx) / 2, (ay + by) / 2 - 14];
|
||||
map.upright(context, mx, my, () => {
|
||||
context.font = "12px system-ui, sans-serif";
|
||||
@@ -75,30 +121,33 @@ export function createPlanMeasure(heightAt: () => (x: number, y: number) => numb
|
||||
context.fillText(text, mx, my);
|
||||
});
|
||||
} else {
|
||||
context.beginPath();
|
||||
context.arc(ax, ay, 4, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
dot(ax, ay);
|
||||
}
|
||||
},
|
||||
press(px, py, map, button) {
|
||||
if (button !== 0) return false;
|
||||
const point = map.toMetric(px, py);
|
||||
if (!from || fixed) {
|
||||
[from, to, fixed] = [point, null, false];
|
||||
// 첫 누름 — 노선 위(선 8 px · 꺾임점 12 px)면 노선 따라 · 아니면 곧은 거리
|
||||
const net = network();
|
||||
const hit = lineAt(net, px, py, map);
|
||||
fromPos = hit ? { line: net.lines[hit.line].id, atM: hit.atM } : null;
|
||||
[from, to, fixed, span] = [hit ? hit.point : point, null, false, null];
|
||||
} else {
|
||||
[to, fixed] = [point, true];
|
||||
place(point);
|
||||
fixed = true;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
hover(px, py, map) {
|
||||
if (from && !fixed) to = map.toMetric(px, py);
|
||||
if (from && !fixed) place(map.toMetric(px, py));
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
layer,
|
||||
clear: () => {
|
||||
[from, to, fixed] = [null, null, false];
|
||||
[from, to, fixed, fromPos, span] = [null, null, false, null, null];
|
||||
},
|
||||
result: () => (from && to && fixed ? measure(from, to) : null),
|
||||
};
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
import "./D03_PlanAnalysis_UI_Style.css";
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
|
||||
import { pickFiles } from "@ui/ui_template_drop";
|
||||
import { askConfirm } from "@ui/ui_template_modal";
|
||||
import { createProgressBar } from "@ui/ui_template_progress";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import {
|
||||
createButton,
|
||||
@@ -71,10 +72,8 @@ import {
|
||||
import { lonLatToTm, tmParams } from "./logic/D03_PlanAnalysis_Tm";
|
||||
|
||||
const POLL_MS = 1000;
|
||||
/** 지도 보기 가운데(보기의 반)가 받은 범위 밖이면 이어 받음 — 그 상자가 이보다 넓으면(멀리 줄여 봄) 안 받음(m). */
|
||||
const VIEW_FETCH_MAX_M = 2000;
|
||||
/** 지도 보기 바뀐 뒤 이만큼 멈추면 범위를 봄(ms). */
|
||||
const VIEW_SETTLE_MS = 800;
|
||||
/** 받기가 끊김(서버 다시 켜짐 · 진행을 못 읽음) — [다시 받기]. */
|
||||
const INTERRUPTED = "끊김(서버 다시 켜짐 등)";
|
||||
/** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */
|
||||
const SITE_RADIUS_M = 1000;
|
||||
/** 되돌리기 쌓는 수. */
|
||||
@@ -110,9 +109,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
let redoStack: Snap[] = [];
|
||||
let surface: PlanSurface | null = null;
|
||||
let have: [number, number, number, number] | null = null;
|
||||
let viewCore: Vertex[] = []; // 지도 보기 가운데 상자 두 귀(넓으면 빈 것)
|
||||
let viewMid: Vertex | null = null; // 지도 보기 가운데(지점 [추가] 자리)
|
||||
let viewTimer = 0;
|
||||
let lastTried = ""; // 같은 받기를 거듭하지 않음
|
||||
let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음)
|
||||
let busy = false;
|
||||
@@ -160,7 +157,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
addPoint: (at) => step({ network, points: [...points, at], pointNames: [...pointNames, ""] }),
|
||||
});
|
||||
const pieces = createPlanPieces({ network: () => network, edit });
|
||||
const measure = createPlanMeasure(() => z);
|
||||
const measure = createPlanMeasure(
|
||||
() => z,
|
||||
() => network,
|
||||
);
|
||||
const marks = createPlanPoints({
|
||||
points: () => points,
|
||||
network: () => network,
|
||||
@@ -184,30 +184,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
draw,
|
||||
editable: () => editable,
|
||||
});
|
||||
// 지도 보기 가운데 상자 — 그릴 때마다 보이는 화면 가운데 반을 작업 좌표로(바뀌면 잠시 뒤 범위 봄)
|
||||
// 지도 보기 가운데 — 그릴 때마다 작업 좌표로(지점 [추가] 자리 · 보기만 옮김 = 안 받음 596-4)
|
||||
const viewWatch: PlanMapLayer = {
|
||||
draw(context, screen) {
|
||||
const [w, h] = [context.canvas.clientWidth, context.canvas.clientHeight];
|
||||
const corners = [
|
||||
[w / 4, h / 4],
|
||||
[(3 * w) / 4, h / 4],
|
||||
[(3 * w) / 4, (3 * h) / 4],
|
||||
[w / 4, (3 * h) / 4],
|
||||
].map(([x, y]) => screen.toMetric(...screen.unrotate(x, y)));
|
||||
const xs = corners.map((c) => c[0]);
|
||||
const ys = corners.map((c) => c[1]);
|
||||
viewMid = w ? screen.toMetric(...screen.unrotate(w / 2, h / 2)) : null;
|
||||
const box: Vertex[] = [
|
||||
[Math.min(...xs), Math.min(...ys)],
|
||||
[Math.max(...xs), Math.max(...ys)],
|
||||
];
|
||||
const wide =
|
||||
box[1][0] - box[0][0] > VIEW_FETCH_MAX_M || box[1][1] - box[0][1] > VIEW_FETCH_MAX_M;
|
||||
const next = wide || !w ? [] : box;
|
||||
if (JSON.stringify(next) === JSON.stringify(viewCore)) return;
|
||||
viewCore = next;
|
||||
clearTimeout(viewTimer);
|
||||
viewTimer = window.setTimeout(() => void ensureCovered(), VIEW_SETTLE_MS);
|
||||
},
|
||||
};
|
||||
const map = createPlanMap({
|
||||
@@ -220,16 +201,18 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
projectId,
|
||||
map,
|
||||
points: () => [...points, ...allVertices()],
|
||||
have: () => have,
|
||||
});
|
||||
map.root.append(backdrop.bar, backdrop.legend, props.panel);
|
||||
// 받기 진행 = 지도 오른쪽 아래 작은 서클(받을 때만 · 지도 조작 안 막음 596-3)
|
||||
const circle = createProgressCircle({ size: 40, corner: true });
|
||||
circle.root.hidden = true;
|
||||
map.root.append(backdrop.bar, backdrop.legend, props.panel, circle.root);
|
||||
const main = document.createElement("div");
|
||||
main.className = "d03-plan";
|
||||
main.append(map.root);
|
||||
|
||||
const terrainText = createText("", "muted");
|
||||
terrainText.classList.add("d03-plan__hint");
|
||||
const bar = createProgressBar();
|
||||
bar.root.hidden = true;
|
||||
const retryButton = createButton({
|
||||
label: "다시 받기",
|
||||
variant: "filled",
|
||||
@@ -240,7 +223,18 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
},
|
||||
});
|
||||
const terrainCard = createPanelCard("지형", false);
|
||||
terrainCard.append(terrainText, bar.root, retryButton);
|
||||
const roadButton = createButton({
|
||||
label: "임도망 올리기",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () =>
|
||||
pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, (files) => {
|
||||
void backdrop.uploadRoads(files);
|
||||
}),
|
||||
});
|
||||
roadButton.title =
|
||||
"임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)";
|
||||
terrainCard.append(terrainText, retryButton, roadButton);
|
||||
const pointCard = createPanelCard("지점", false);
|
||||
pointCard.append(marks.panel);
|
||||
const drawCard = createPanelCard("그리기", false);
|
||||
@@ -341,13 +335,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
list.every(([x, y]) => x >= have![0] && x <= have![2] && y >= have![1] && y <= have![3]);
|
||||
const outside = (): boolean => !!have && !inside([...points, ...allVertices()]);
|
||||
|
||||
/** 조용히 받기(요구 453) — 지점 · 선 · 지도 보기 가운데가 받은 범위 밖이면 이어 받음 ·
|
||||
* 단추 · 막는 창 없음 · 실패 = [다시 받기] · 같은 요청은 한 번만. */
|
||||
/** 조용히 받기(요구 453) — 지점 · 선이 받은 범위 밖이면 이어 받음(지도 보기만 옮김 = 안 받음 596-4) ·
|
||||
* 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */
|
||||
async function ensureCovered(): Promise<void> {
|
||||
if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return;
|
||||
const need = [...points, ...allVertices()];
|
||||
if (inside(need) && inside(viewCore)) return; // 받을 때 서버가 여유를 더함
|
||||
const extra = [...need, ...viewCore];
|
||||
const extra = [...points, ...allVertices()];
|
||||
if (inside(extra)) return; // 받을 때 서버가 여유를 더함
|
||||
const key = JSON.stringify([have, extra]);
|
||||
if (key === lastTried) return;
|
||||
lastTried = key;
|
||||
@@ -369,6 +362,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
pieces.render();
|
||||
marks.render();
|
||||
retryButton.hidden = !editable || busy || !failed;
|
||||
roadButton.hidden = !editable;
|
||||
if (!busy)
|
||||
terrainText.textContent = failed
|
||||
? `지형 받기 실패 — ${failed}`
|
||||
@@ -407,29 +401,32 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
await ensureCovered(); // 받는 동안 옮긴 지점 · 선 · 보기
|
||||
}
|
||||
|
||||
/** 지형 받기를 끝날 때까지 지켜봄 — 진행은 지형 칸에. */
|
||||
/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 지도 귀퉁이 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 ·
|
||||
* 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */
|
||||
async function watch(): Promise<PlanJob | null> {
|
||||
busy = true;
|
||||
bar.root.hidden = false;
|
||||
circle.set(null, "지형");
|
||||
circle.root.hidden = false;
|
||||
refresh();
|
||||
try {
|
||||
for (;;) {
|
||||
if (!root.isConnected) return null;
|
||||
const job = (await fetchPlanProgress(projectId)).terrain;
|
||||
bar.set(job.percent / 100);
|
||||
terrainText.textContent = `지형 받기 ${job.percent}% · ${job.message ?? ""}`;
|
||||
circle.set(job.percent / 100, `지형 ${job.message ?? ""}`.trim());
|
||||
circle.root.title = `지형 받기 ${job.percent}% · ${job.message ?? ""}`;
|
||||
if (!job.running) {
|
||||
const cut = !job.error && (job.interrupted || job.percent < 100);
|
||||
if (job.error) showToast(job.error, "error");
|
||||
return job;
|
||||
return cut ? { ...job, error: INTERRUPTED } : job;
|
||||
}
|
||||
await sleep(POLL_MS);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
||||
return null;
|
||||
return { running: false, percent: 0, message: null, error: INTERRUPTED };
|
||||
} finally {
|
||||
busy = false;
|
||||
bar.root.hidden = true;
|
||||
circle.root.hidden = true;
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,3 +195,21 @@
|
||||
.d03-plan__piece-year {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 599 그리기 칸 — 방식 칸(마디 고름 2 × 2 · 길이 · 기울기 두 열) */
|
||||
.d03-plan__draw-kind {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.d03-plan__draw-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-8);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.d03-plan__draw-fields .ui-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_Measure.ts
|
||||
* D 예정노선 거리재기 「노선 따라」(PLAN 600-2 · 요구 463-23) — 첫 누름이 노선 위면 둘째 누름까지
|
||||
* 노선(망)을 따라간 길 · 같은 선 = 누가 차 · 갈래 ↔ 본선 = 갈래 고리(`branch`)를 따라 올라가 만나는 점까지.
|
||||
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
|
||||
* 서로 이어지지 않은 본선끼리 = 길 없음(null) → 부르는 쪽이 곧은 거리로.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
distanceAlong,
|
||||
lineLength,
|
||||
pointAtDistance,
|
||||
segmentLength,
|
||||
sliceLine,
|
||||
} from "./D03_PlanAnalysis_Draw";
|
||||
import type { PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan";
|
||||
|
||||
/** 노선 위 한 자리 — 선 id · 그 선 위 누가(m). */
|
||||
export interface RoutePos {
|
||||
line: string;
|
||||
atM: number;
|
||||
}
|
||||
|
||||
export interface RouteSpan {
|
||||
lengthM: number;
|
||||
/** 두 자리 사이 노선 따라 꺾은선(앞 자리 → 뒤 자리). */
|
||||
path: Vertex[];
|
||||
/** 같은 선 위일 때만 — 두 누가(m). */
|
||||
stations: [number, number] | null;
|
||||
}
|
||||
|
||||
/** 자리에서 시작해 갈래 고리를 따라 올라간 (선 · 그 선에 들어선 누가) 목록 — 첫 항목 = 자리 자신. */
|
||||
function upChain(network: PlanNetwork, pos: RoutePos): RoutePos[] {
|
||||
const chain: RoutePos[] = [pos];
|
||||
const seen = new Set([pos.line]);
|
||||
let line = network.lines.find((entry) => entry.id === pos.line);
|
||||
while (line?.branch) {
|
||||
const parent = network.lines.find((entry) => entry.id === line!.branch!.line);
|
||||
if (!parent || seen.has(parent.id)) break;
|
||||
seen.add(parent.id);
|
||||
chain.push({
|
||||
line: parent.id,
|
||||
atM: lineLength(parent.vertices.slice(0, line.branch.vertex + 1)),
|
||||
});
|
||||
line = parent;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
/** 두 자리 사이 노선 따라 길 — 이어지지 않으면 null. */
|
||||
export function routeSpan(network: PlanNetwork, a: RoutePos, b: RoutePos): RouteSpan | null {
|
||||
const up = upChain(network, a);
|
||||
const down = upChain(network, b);
|
||||
const ia = up.findIndex((step) => down.some((other) => other.line === step.line));
|
||||
if (ia < 0) return null;
|
||||
const ib = down.findIndex((step) => step.line === up[ia].line);
|
||||
const legs: Array<[string, number, number]> = [];
|
||||
// a → 갈래 첫 점(0 m) → 붙은 선 … 만나는 선
|
||||
for (let k = 0; k < ia; k += 1) legs.push([up[k].line, up[k].atM, 0]);
|
||||
legs.push([up[ia].line, up[ia].atM, down[ib].atM]);
|
||||
// 만나는 선 → … → b
|
||||
for (let k = ib - 1; k >= 0; k -= 1) legs.push([down[k].line, 0, down[k].atM]);
|
||||
const path: Vertex[] = [];
|
||||
let lengthM = 0;
|
||||
for (const [id, from, to] of legs) {
|
||||
const vertices = network.lines.find((entry) => entry.id === id)?.vertices ?? [];
|
||||
if (vertices.length < 2) continue;
|
||||
lengthM += Math.abs(to - from);
|
||||
const piece = sliceLine(vertices, from, to);
|
||||
if (from > to) piece.reverse();
|
||||
for (const point of piece) {
|
||||
const last = path[path.length - 1];
|
||||
if (!last || segmentLength(last, point) > 1e-9) path.push(point);
|
||||
}
|
||||
}
|
||||
const same = ia === 0 && ib === 0;
|
||||
return { lengthM, path, stations: same ? [a.atM, b.atM] : null };
|
||||
}
|
||||
|
||||
/** 점에서 가장 가까운 노선 위 자리 — `from` 과 이어진 선 안에서만. */
|
||||
export function nearestOnRoute(
|
||||
network: PlanNetwork,
|
||||
point: Vertex,
|
||||
from: RoutePos,
|
||||
): (RoutePos & { point: Vertex }) | null {
|
||||
let best: (RoutePos & { point: Vertex }) | null = null;
|
||||
let bestGap = Infinity;
|
||||
for (const line of network.lines) {
|
||||
if (line.vertices.length < 2) continue;
|
||||
const atM = distanceAlong(line.vertices, point);
|
||||
const gap = segmentLength(pointAtDistance(line.vertices, atM), point);
|
||||
if (gap >= bestGap || !routeSpan(network, from, { line: line.id, atM })) continue;
|
||||
best = { line: line.id, atM, point: pointAtDistance(line.vertices, atM) };
|
||||
bestGap = gap;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
* D03_PlanAnalysis_Plan.ts
|
||||
* D 예정노선 2단계 손 긋기 꼴(PLAN 562-3 · 580) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기 ·
|
||||
* 선 뒤집기 · 잇기 · 꺾임점 옮김 · 지움 · 선 지움 · 선 통째 연도 · 마크 옮김 · 여러 조각 연도 · 끌어 고르기 판정(585-2) ·
|
||||
* 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3) · 선 이름 · 노드 기록 꼴 · 이름 검사(594-1).
|
||||
* 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3) · 선 이름 · 노드 기록 꼴 · 이름 검사(594-1) ·
|
||||
* 노드 기록이 꼭짓점과 같이 움직임(599-2 · 기록 만들기 · 어긋남 = `D03_PlanAnalysis_Span`).
|
||||
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
|
||||
* [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음.
|
||||
* ========================================================================== */
|
||||
@@ -21,6 +22,15 @@ export interface NodeRecord {
|
||||
slope_pct: number | null;
|
||||
}
|
||||
|
||||
/** 꼭짓점이 바뀐 선 — 기록 있는 선만 `edit` 로 기록도 같이(없는 선 = 그대로 없음). */
|
||||
function withNodes(
|
||||
line: PlanLine,
|
||||
vertices: Vertex[],
|
||||
edit: (nodes: Array<NodeRecord | null>) => Array<NodeRecord | null>,
|
||||
): PlanLine {
|
||||
return line.nodes ? { ...line, vertices, nodes: edit([...line.nodes]) } : { ...line, vertices };
|
||||
}
|
||||
|
||||
/** 그은 선 하나 — `branch` 없음 = 본선(시점 · 종점에서) · 있음 = 갈래(붙은 선 · 꺾임점 차례) ·
|
||||
* 꼭짓점 차례 = 측점 차례(첫 점 = No.0) · `name` 없음 = 차례 이름(`lineNames`) ·
|
||||
* `nodes` = 꼭짓점마다 기록(같은 길이 · 칸 없음 = 기록 없음 · 594-1). */
|
||||
@@ -201,7 +211,7 @@ export function insertVertex(
|
||||
if (i === line) {
|
||||
const vertices = [...entry.vertices];
|
||||
vertices.splice(segment + 1, 0, point);
|
||||
return { ...entry, vertices };
|
||||
return withNodes(entry, vertices, (nodes) => (nodes.splice(segment + 1, 0, null), nodes));
|
||||
}
|
||||
const branch = entry.branch;
|
||||
return branch && branch.line === id && branch.vertex > segment
|
||||
@@ -211,13 +221,21 @@ export function insertVertex(
|
||||
};
|
||||
}
|
||||
|
||||
/** 선 끝에 점을 더함. */
|
||||
export function appendVertex(network: PlanNetwork, line: number, point: Vertex): PlanNetwork {
|
||||
/** 선 끝에 점을 더함 — `record` = 그 노드 긋기 방식 · 값(599-2 · 처음 기록이면 앞 노드 = 기록 없음). */
|
||||
export function appendVertex(
|
||||
network: PlanNetwork,
|
||||
line: number,
|
||||
point: Vertex,
|
||||
record: NodeRecord | null = null,
|
||||
): PlanNetwork {
|
||||
return {
|
||||
...network,
|
||||
lines: network.lines.map((entry, i) =>
|
||||
i === line ? { ...entry, vertices: [...entry.vertices, point] } : entry,
|
||||
),
|
||||
lines: network.lines.map((entry, i) => {
|
||||
if (i !== line) return entry;
|
||||
const vertices = [...entry.vertices, point];
|
||||
const nodes = entry.nodes ?? (record ? entry.vertices.map(() => null) : null);
|
||||
return nodes ? { ...entry, vertices, nodes: [...nodes, record] } : { ...entry, vertices };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -233,7 +251,9 @@ export function dropLastVertex(network: PlanNetwork, line: number): PlanNetwork
|
||||
return {
|
||||
...network,
|
||||
lines: network.lines.map((other, i) =>
|
||||
i === line ? { ...other, vertices: other.vertices.slice(0, last) } : other,
|
||||
i === line
|
||||
? withNodes(other, other.vertices.slice(0, last), (nodes) => nodes.slice(0, last))
|
||||
: other,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -430,7 +450,8 @@ export function removeVertex(
|
||||
};
|
||||
return {
|
||||
lines: network.lines.map((other, i) => {
|
||||
if (i === line) return { ...other, vertices };
|
||||
if (i === line)
|
||||
return withNodes(other, vertices, (nodes) => nodes.filter((_, k) => k !== vertex));
|
||||
const branch = other.branch;
|
||||
return branch?.line === entry.id && branch.vertex > vertex
|
||||
? { ...other, branch: { ...branch, vertex: branch.vertex - 1 } }
|
||||
@@ -448,7 +469,10 @@ export function reverseLine(network: PlanNetwork, line: number): PlanNetwork | s
|
||||
const length = round3(lineLength(entry.vertices));
|
||||
return {
|
||||
lines: network.lines.map((other, i) => {
|
||||
if (i === line) return { ...other, vertices: [...other.vertices].reverse() };
|
||||
if (i === line)
|
||||
return withNodes(other, [...other.vertices].reverse(), (nodes) =>
|
||||
nodes.map((_, k) => (k ? nodes[n - k] : null)),
|
||||
);
|
||||
const branch = other.branch;
|
||||
return branch?.line === entry.id
|
||||
? { ...other, branch: { ...branch, vertex: n - 1 - branch.vertex } }
|
||||
@@ -502,7 +526,13 @@ function joinLines(network: PlanNetwork, a: number, b: number): PlanNetwork {
|
||||
);
|
||||
const lines = network.lines
|
||||
.map((line, i) => {
|
||||
if (i === a) return { ...line, vertices: [...line.vertices, ...tail.vertices.slice(1)] };
|
||||
if (i === a) {
|
||||
const vertices = [...line.vertices, ...tail.vertices.slice(1)];
|
||||
if (!line.nodes && !tail.nodes) return { ...line, vertices };
|
||||
const nodesOf = (entry: PlanLine): Array<NodeRecord | null> =>
|
||||
entry.nodes ?? entry.vertices.map(() => null);
|
||||
return { ...line, vertices, nodes: [...nodesOf(line), ...nodesOf(tail).slice(1)] };
|
||||
}
|
||||
const branch = line.branch;
|
||||
return branch?.line === tail.id
|
||||
? { ...line, branch: { line: head.id, vertex: branch.vertex + shift } }
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_Span.ts
|
||||
* D 예정노선 다시 긋기(PLAN 599-3 · 요구 463-12) — 선 하나의 두 꺾임점 사이를 새로 그은 점들로 바꿈.
|
||||
* 끝 두 점(앞 노드 · 뒤 노드) 그대로 · 속 꺾임점에 갈래가 붙었으면 막음(까닭 글) · 뒤 갈래 차례 밈 ·
|
||||
* 마크(조각 경계) = 바뀐 구간 안은 길이 비율 · 뒤는 길이 차만큼 · 조각 id 그대로(업체 열쇠).
|
||||
* 노드 기록(599-2) — 그리기 칸 값 → 기록 · 옮긴 노드 어긋남(⚠) · 다시 그은 속 노드 = 그때 기록 · 뒤 노드 = 자유.
|
||||
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { groundSlopePct, lineLength, segmentLength, type HeightAt } from "./D03_PlanAnalysis_Draw";
|
||||
import type { NodeKind, NodeRecord, PlanLine, PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan";
|
||||
|
||||
const round3 = (value: number): number => Math.round(value * 1000) / 1000;
|
||||
|
||||
/** 그리기 칸 방식 · 값 → 노드 기록(안 쓴 값 = null · 599-2). */
|
||||
export function nodeRecord(kind: NodeKind, lengthM: number, slopePct: number): NodeRecord {
|
||||
return {
|
||||
kind,
|
||||
length_m: kind === "length" || kind === "both" ? lengthM : null,
|
||||
slope_pct: kind === "slope" || kind === "both" ? slopePct : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 노드 기록과 지금 앞 토막이 어긋남(옮긴 노드 ⚠ · 599-2) — 길이 1 cm · |기울기| 0.1 % 넘게 · 지형 밖 = 안 봄. */
|
||||
export function nodeOff(line: PlanLine, vertex: number, z: HeightAt): boolean {
|
||||
const record = line.nodes?.[vertex];
|
||||
if (!record || vertex < 1) return false;
|
||||
const [a, b] = [line.vertices[vertex - 1], line.vertices[vertex]];
|
||||
if (record.length_m !== null && Math.abs(segmentLength(a, b) - record.length_m) > 0.01)
|
||||
return true;
|
||||
const slope = record.slope_pct === null ? null : groundSlopePct(a, b, z);
|
||||
return slope !== null && Math.abs(Math.abs(slope) - Math.abs(record.slope_pct!)) > 0.1;
|
||||
}
|
||||
|
||||
/** `line` 째 선 `from`~`to` 꺾임점 사이를 다시 그을 수 있나 — 못 하면 까닭 글. */
|
||||
export function respanBlocked(
|
||||
network: PlanNetwork,
|
||||
line: number,
|
||||
from: number,
|
||||
to: number,
|
||||
): string | null {
|
||||
const entry = network.lines[line];
|
||||
if (!entry || !(from >= 0 && to > from && to < entry.vertices.length))
|
||||
return "다시 그을 구간을 고르세요.";
|
||||
if (
|
||||
network.lines.some(
|
||||
(o) => o.branch?.line === entry.id && o.branch.vertex > from && o.branch.vertex < to,
|
||||
)
|
||||
)
|
||||
return "구간 속 점에 갈래가 붙어 있습니다 — 갈래를 먼저 지우세요.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `line` 째 선 `from`~`to` 꺾임점 사이 속 점을 `through` 로 바꿈(끝 두 점 그대로) — 막히면 까닭 글.
|
||||
* `records` = 속 점 기록(`through` 와 같은 길이) · 뒤 노드 기록 = 자유 · 기록 없는 선 + 기록 없음 = 그대로 없음. */
|
||||
export function replaceSpan(
|
||||
network: PlanNetwork,
|
||||
line: number,
|
||||
from: number,
|
||||
to: number,
|
||||
through: ReadonlyArray<Vertex>,
|
||||
records: ReadonlyArray<NodeRecord | null> = [],
|
||||
): PlanNetwork | string {
|
||||
const blocked = respanBlocked(network, line, from, to);
|
||||
if (blocked) return blocked;
|
||||
const entry = network.lines[line];
|
||||
const old = entry.vertices;
|
||||
const vertices: Vertex[] = [
|
||||
...old.slice(0, from + 1),
|
||||
...through.map(([x, y]): Vertex => [x, y]),
|
||||
...old.slice(to),
|
||||
];
|
||||
const shift = through.length - (to - from - 1);
|
||||
const at = (list: ReadonlyArray<Vertex>, i: number): number => lineLength(list.slice(0, i + 1));
|
||||
const [startM, oldEndM, newEndM] = [at(old, from), at(old, to), at(vertices, to + shift)];
|
||||
const carry = (m: number): number =>
|
||||
m <= startM
|
||||
? m
|
||||
: m >= oldEndM
|
||||
? m - oldEndM + newEndM
|
||||
: startM + ((m - startM) / (oldEndM - startM)) * (newEndM - startM);
|
||||
const branched = (other: PlanLine): PlanLine =>
|
||||
other.branch?.line === entry.id && other.branch.vertex >= to
|
||||
? { ...other, branch: { ...other.branch, vertex: other.branch.vertex + shift } }
|
||||
: other;
|
||||
return {
|
||||
lines: network.lines.map((other, i) => {
|
||||
if (i !== line) return branched(other);
|
||||
if (!other.nodes && !records.some(Boolean)) return { ...other, vertices };
|
||||
const nodes = other.nodes ?? old.map(() => null);
|
||||
const inner = through.map((_, k) => records[k] ?? null);
|
||||
const end = nodeRecord("free", 0, 0);
|
||||
return {
|
||||
...other,
|
||||
vertices,
|
||||
nodes: [...nodes.slice(0, from + 1), ...inner, end, ...nodes.slice(to + 1)],
|
||||
};
|
||||
}),
|
||||
pieces: network.pieces.map((piece) =>
|
||||
piece.line === entry.id
|
||||
? { ...piece, from_m: round3(carry(piece.from_m)), to_m: round3(carry(piece.to_m)) }
|
||||
: piece,
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user