Merge remote-tracking branch 'origin/dev' into sub_laptop_4 — Api(595 받기 꼴은 dev 쪽 · 임도 상자 중복 칸 하나로) · Style(597 · 599 절 둘 다)

This commit is contained in:
2026-10-30 14:49:26 +09:00
19 changed files with 676 additions and 248 deletions
+8 -7
View File
@@ -37,17 +37,18 @@ export interface PlanJob {
percent: number;
message: string | null;
error: string | null;
/** 서버 다시 켜짐 등으로 끊김(595-3). */
interrupted?: boolean;
}
/** 받기 답(595-2) — 시작 · 멀다(받은 범위에서 5 km 넘음) · 다른 TM 띠 — 둘은 확인 뒤 `reset`. */
/** 받기 시작 답(595-2) — 받음 · 멂(받은 범위에서 5 km 넘음 → 묻고 `reset`) · 다른 TM 띠(노선 지움 확인 뒤 `reset`). */
export type TerrainStart =
| ({ status: "accepted" } & PlanJob)
| (PlanJob & { status?: "accepted" })
| { status: "far"; distance_m: number; message: string }
| { status: "band"; epsg: number; center_epsg: number; message: string };
/** 위치 칸 기준점(경위도 · 캐시) · 새로 받기(옛 받은 범위 버림). */
export interface TerrainAim {
/** 위치 칸 기준점(캐시 · [저장] 전) — 서버가 둘레 ±1 km 를 더 덮음 · `reset` = 옛 받은 범위 버림. */
export interface TerrainCenter {
center?: { lon: number; lat: number } | null;
reset?: boolean;
}
@@ -118,7 +119,7 @@ export const fetchTerrainExtent = (
projectId: string,
marginM: number | null,
extra: Vertex[],
center: { lon: number; lat: number } | null = null,
center: TerrainCenter["center"] = null,
): Promise<TerrainExtent> => {
const query = new URLSearchParams();
if (marginM !== null) query.set("margin_m", String(marginM));
@@ -135,11 +136,11 @@ export const startTerrain = (
projectId: string,
marginM: number | null,
extra: Vertex[],
aim: TerrainAim = {},
where: TerrainCenter = {},
): Promise<TerrainStart> =>
request(`${base(projectId)}/terrain`, {
method: "POST",
body: JSON.stringify({ margin_m: marginM, extra, ...aim }),
body: JSON.stringify({ margin_m: marginM, extra, ...where }),
});
export const fetchPlanProgress = (projectId: string): Promise<{ terrain: PlanJob }> =>
+125 -122
View File
@@ -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,
@@ -44,23 +44,26 @@ import {
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import { 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 +90,7 @@ export interface PlanDrawParams {
export interface PlanDraw {
layer: PlanMapLayer;
/** 좌측 「그리기」 칸 몸(모드 단추 · 제약 칸 · 안내). */
/** 좌측 「그리기」 칸 몸(`D03_PlanAnalysis_UI_DrawPanel` — 긋기 · 연도 구역 마커 · 방식 칸). */
panel: HTMLElement;
mode: () => DrawMode;
setMode: (mode: DrawMode) => void;
@@ -104,6 +107,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 +122,29 @@ 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[] } | 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 +181,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 +246,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 +260,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: [] };
panelState();
params.onMode();
return true;
}
function setMode(next: DrawMode): void {
@@ -407,12 +376,22 @@ 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;
}
if (span) {
if (!preview.snapped) return (span.through.push(preview.point), true);
const next = replaceSpan(params.network(), spanLine(), span.from, span.to, span.through);
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)
@@ -427,23 +406,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 +482,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 +493,10 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
setMode("idle");
return true;
}
if (mode === "draw" && span && keyIs(event, "plan.drawBack")) {
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 +514,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 +530,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,
};
}
@@ -113,7 +113,7 @@ export function lineAt(
}
/** 그은 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · (extra = 미리보기).
* `active` = 그리는 선(굵은 보라). */
* `active` = 그리는 선(굵은 보라) · `dim` = 다시 긋는 구간(599-3 · 흐리게). */
export function paintNetwork(
context: CanvasRenderingContext2D,
map: ScreenMap,
@@ -121,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";
@@ -129,13 +130,22 @@ export function paintNetwork(
if (line.vertices.length < 2) return;
context.strokeStyle = index === active ? colors.active : routeColor(index);
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,
@@ -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,
},
],
+51 -48
View File
@@ -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),
+3 -11
View File
@@ -35,7 +35,7 @@ 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 규칙 측점과 같음. */
@@ -124,7 +124,7 @@ export interface PlanTools {
view3d: boolean;
/** 거리재기 켜짐(눌림). */
measure: boolean;
/** 「연도 마커」 끌 수 있음(고칠 수 있고 선 있음). */
/** 받기만 — 연도 구역 마커 단추는 그리기 칸(599-4 · `D03_PlanAnalysis_UI_DrawPanel`)이 켬끔. */
yearMark: boolean;
}
@@ -197,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: {
@@ -216,7 +210,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
"Shift+누름 = 구간 더함 · 뺌",
"빈 곳 오른쪽으로 끌기 = 창 안 구간 · 왼쪽으로 = 걸친 구간까지",
"고른 선 위 끌기 = 꺾임점 더해 옮김",
"「연도 마커」 선 위로 끌어 놓기 = 구역 나눔",
"그리기 칸 「연도 구역 마커」 선 위로 끌어 놓기 = 구역 나눔",
"꺾임점 · 마크 끌기 = 옮김 · 마크를 선 밖으로 = 지움(앞 구간과 합침)",
"오른쪽 누름 = 꺾임점 · 마크 지움",
"Delete = 고른 선 · 마크 지움",
@@ -229,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;
@@ -526,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,
+37 -43
View File
@@ -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,
@@ -70,10 +71,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;
/** 되돌리기 쌓는 수. */
@@ -109,9 +108,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;
@@ -167,30 +164,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
idle: () => draw.mode() === "idle" && !measuring,
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({
@@ -203,16 +181,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",
@@ -223,7 +203,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);
@@ -323,13 +314,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;
@@ -351,6 +341,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}`
@@ -389,29 +380,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();
}
}
@@ -213,3 +213,21 @@
.d03-plan__point-edit > .ui-field {
margin: 0;
}
/* 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,73 @@
/* =============================================================================
* D03_PlanAnalysis_Span.ts
* D 예정노선 다시 긋기(PLAN 599-3 · 요구 463-12) — 선 하나의 두 꺾임점 사이를 새로 그은 점들로 바꿈.
* 끝 두 점(앞 노드 · 뒤 노드) 그대로 · 속 꺾임점에 갈래가 붙었으면 막음(까닭 글) · 뒤 갈래 차례 밈 ·
* 마크(조각 경계) = 바뀐 구간 안은 길이 비율 · 뒤는 길이 차만큼 · 조각 id 그대로(업체 열쇠).
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
* ========================================================================== */
import { lineLength } from "./D03_PlanAnalysis_Draw";
import type { PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan";
const round3 = (value: number): number => Math.round(value * 1000) / 1000;
/** `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` 로 바꿈(끝 두 점 그대로) — 막히면 까닭 글. */
export function replaceSpan(
network: PlanNetwork,
line: number,
from: number,
to: number,
through: ReadonlyArray<Vertex>,
): 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);
return {
lines: network.lines.map((other, i) => {
if (i === line) return { ...other, vertices };
const branch = other.branch;
return branch?.line === entry.id && branch.vertex >= to
? { ...other, branch: { ...branch, vertex: branch.vertex + shift } }
: other;
}),
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,
),
};
}
+12 -3
View File
@@ -30,11 +30,13 @@ def test_d03_parts():
three = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts")
css = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css")
draw = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts")
draw_panel = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts")
tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
assert 'createElement("progress")' not in ts and "createProgressBar()" in ts
assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts
assert "createProgressCircle({ size: 40, corner: true })" in ts # 596-3 받기 = 지도 귀퉁이 서클
# 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품
for src in (ts, draw, tools, layers):
for src in (ts, draw, draw_panel, tools, layers):
for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'):
assert raw not in src, raw
# 568-3 — 도구줄 = 공용 지도 도구줄 · 아홉([?] · 되돌리기 · 다시하기 · 3D · 돌리기 둘 · 측점 · 기울기 · 재기)
@@ -76,7 +78,14 @@ def test_d03_parts():
("place", "false"),
], keys
assert ".d03-plan__layers" in css and ".d03-plan__legend" in css
assert "createSegment({" in draw and draw.count("createInputField(") == 2
# 599-1 — 방식 칸 = 마디 고름 2 × 2 · 길이 · 기울기 두 열 · 노드 판(598-4)과 같은 함수
assert "createSegment({" in draw_panel and draw_panel.count("createInputField(") == 2
assert "columns: 2" in draw_panel and "export function createDrawKindFields(" in draw_panel
assert ".d03-plan__draw-fields" in css and "createDrawKindFields(" in draw
assert '"길이·기울기 고정"' in draw_panel and '"둘 다"' not in draw_panel
assert "d03-plan__hint" not in draw # 칸 설명 글 걷음(요구 463-6)
# 599-4 — 연도 구역 마커 = 그리기 칸 단추(끌어 놓기) · 지도 도구줄에서 뺌
assert "YEAR_MARK_DRAG" in draw_panel and '"year-mark"' not in tools
props = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts") # 585-2 조각 연도 = 속성 판
assert "createSelectField(" in props and "createFloatPanel(" in props
assert 'createElement("button")' not in props and 'createElement("select")' not in props
-1
View File
@@ -55,7 +55,6 @@ def test_단추_목록_아이콘_차례():
"footprint",
"measure",
"station-snap",
"year-mark",
]
names = _icon_names()
assert all(icon in names for _, icon, _ in rows)
+101
View File
@@ -0,0 +1,101 @@
"""D03 다시 긋기(PLAN 599-3 · 요구 463-12) — TS `logic/D03_PlanAnalysis_Span.ts`.
두 꺾임점 사이를 새 점으로 바꿈 · 끝 두 점 그대로 · 뒤 갈래 차례 밈 · 속 점 갈래 = 막음 ·
마크 = 바뀐 구간 안 길이 비율 · 뒤는 길이 차 · 조각 id 그대로.
./venv/Scripts/python.exe -m pytest resources/tester/test_599_3_plan_span.py -q
"""
from __future__ import annotations
import json
import math
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Span.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { writeFileSync } from "node:fs";
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
import * as span from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.js";
const Y = 2026;
let n = plan.newLine(plan.emptyNetwork(), [0, 0]);
for (const p of [[100, 0], [200, 0], [300, 0]]) n = plan.appendVertex(n, 0, p);
n = plan.normalizePieces(n, Y);
n = plan.normalizePieces(plan.splitPiece(n, "main", 150), Y);
n = plan.normalizePieces(plan.splitPiece(n, "main", 250), Y);
n = plan.addBranch(n, 0, 2);
n = plan.normalizePieces(plan.appendVertex(n, 1, [200, 80]), Y);
const done = span.replaceSpan(n, 0, 1, 2, [[150, 50]]);
const wide = span.replaceSpan(n, 0, 0, 3, [[150, 50]]);
const bad = span.replaceSpan(n, 0, 2, 2, []);
const straight = span.replaceSpan(n, 0, 0, 2, []);
writeFileSync(process.argv[2], JSON.stringify({ before: n, done, wide, bad, straight }));
"""
def _run_ts(tmp_path: Path) -> dict:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(TS_FILE),
"--outDir",
str(out),
"--rootDir",
str(ROOT),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
result = tmp_path / "out.json"
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def test_replace_span(tmp_path: Path) -> None:
r = _run_ts(tmp_path)
done = r["done"]
main = done["lines"][0]
assert main["vertices"] == [[0, 0], [100, 0], [150, 50], [200, 0], [300, 0]]
assert done["lines"][1]["branch"] == {"line": "main", "vertex": 3} # 뒤 갈래 한 칸 밈
new_span = 2 * math.hypot(50, 50)
pieces = [(p["id"], p["from_m"], p["to_m"]) for p in done["pieces"] if p["line"] == "main"]
before = [p["id"] for p in r["before"]["pieces"] if p["line"] == "main"]
assert [p[0] for p in pieces] == before # 조각 id 그대로
assert pieces[1][1] == round(100 + new_span / 2, 3) # 구간 속 마크 = 길이 비율
assert pieces[2][1] == round(250 - 200 + 100 + new_span, 3) # 뒤 마크 = 길이 차
assert pieces[2][2] == round(300 - 200 + 100 + new_span, 3)
def test_blocked_and_straight(tmp_path: Path) -> None:
r = _run_ts(tmp_path)
assert "갈래" in r["wide"] # 속 점(2)에 갈래
assert "고르세요" in r["bad"]
straight = r["straight"]["lines"][0]["vertices"]
assert straight == [[0, 0], [200, 0], [300, 0]] # 속 점 없이 곧게
assert r["straight"]["lines"][1]["branch"]["vertex"] == 1
-1
View File
@@ -31,7 +31,6 @@ export const MAP_TOOLS = {
footprint: { icon: "road", title: "절/성토선", toggle: true },
measure: { icon: "ruler", title: "거리 재기", toggle: true },
"station-snap": { icon: "magnet", title: "측점 스냅", toggle: true },
"year-mark": { icon: "flag", title: "연도 구역 마커 — 선 위로 끌어 놓기" },
} satisfies Record<string, { icon: IconName; title: string; toggle?: boolean }>;
export type MapToolAct = keyof typeof MAP_TOOLS;
+9
View File
@@ -19,6 +19,15 @@
justify-content: center;
}
/* 오른쪽 아래 귀퉁이 — 지도 가운데(그리는 자리)를 안 가림 · 글은 원 왼쪽 */
.ui-progress-circle--corner {
position: absolute;
z-index: 1;
right: var(--spacing-8);
bottom: var(--spacing-8);
flex-direction: row-reverse;
}
.ui-progress-circle__dial {
position: relative;
width: var(--ui-progress-size);
+7 -1
View File
@@ -4,6 +4,7 @@ import "./ui_template_progress.css";
* ui_template_progress.ts
* 부품 「진행 막대」(552-3 · 목록 30) — 막대 · 쌓임 막대 · 원.
* 꼴 = 막대(`createProgressBar` · 값 하나) · 쌓임 막대(`parts` · 갈래 셋 accent · soft · warn) · 원(`createProgressCircle` · 아래)
* · 원 자리 = 흐름(기본) · 가운데 덮음(`overlay`) · 오른쪽 아래 귀퉁이(`corner` · 글은 원 왼쪽)
* 크기 = 막대 굵기 6 · 8(`thick` → data-size) · 원 지름 72(기본)
* 상태 = 기본 · 오류(`.is-error` · 막대가 위험색) · 판정(`tone`: warn · bad)
* 쓰는 법 = `createProgressBar({ value: 0.4, label: "40 %" })` · `bar.set(0.8)` · `createProgressBar({ parts: [{ value: 3, tone: "accent" }, { value: 1, tone: "warn" }] })`
@@ -28,6 +29,8 @@ export interface ProgressCircleOptions {
label?: string;
/** 컨테이너 정중앙에 띄우는 오버레이로 만든다(컨테이너는 position: relative 여야 한다). */
overlay?: boolean;
/** 컨테이너 오른쪽 아래 귀퉁이에 작게 띄운다(가운데를 안 가림 · 컨테이너는 position: relative). */
corner?: boolean;
}
export interface ProgressCircleHandle {
@@ -40,7 +43,10 @@ export interface ProgressCircleHandle {
export function createProgressCircle(options: ProgressCircleOptions = {}): ProgressCircleHandle {
const root = document.createElement("div");
root.className = "ui-progress-circle" + (options.overlay ? " ui-progress-circle--overlay" : "");
root.className =
"ui-progress-circle" +
(options.overlay ? " ui-progress-circle--overlay" : "") +
(options.corner ? " ui-progress-circle--corner" : "");
root.setAttribute("role", "status");
root.setAttribute("aria-live", "polite");
if (options.size) root.style.setProperty("--ui-progress-size", `${options.size}px`);
+13
View File
@@ -48,6 +48,19 @@
cursor: not-allowed;
}
/* 여러 줄(columns) — 칸 사이 1px 틈에 틀 색 바탕이 보여 줄 · 칸 나눔선 */
.ui-segment[data-columns] {
display: grid;
grid-template-columns: repeat(var(--ui-segment-columns), 1fr);
gap: 1px;
height: auto;
background: var(--color-border);
}
.ui-segment[data-columns] > .ui-segment__item {
height: var(--ui-size-h);
border-left: 0;
}
/* 숨김 — display 가 [hidden] 을 덮지 않게(공용 기본 꼴이 안 실린 곳 · 페이지 꾸밈 위에서도) */
:is(.ui-segment, .ui-segment__item)[hidden] {
display: none !important;
+7
View File
@@ -4,6 +4,7 @@
*
* 꼴 = 글 · 아이콘(+ title) · 글 + 아이콘
* 크기 = data-size 20 · 24 · 28 · 32(기본 28)
* 줄 = 한 줄(기본) · `columns: 2` = 2 × 2 처럼 여러 줄 칸(긴 이름 넷 · 599-1 D03 긋기 방식)
* 상태 = 마우스 `:hover` · 눌림 `:active` · 막힘 `disabled` · 고름 = aria-checked — CSS 는 `[data-state]` 로도 받음
* 쓰기 = `createSegment({ options: [{ value: "list", label: "목록" }, { value: "card", label: "카드" }], value: "list", onChange })`
* 대신 = B01 `.b01-sys-seg` · `.b01-home__seg` · `.b01-proj-seg` · B04 `b04-surface__sheet-method` · B05 `b05-routerec__pills` ·
@@ -37,12 +38,18 @@ export function createSegment(opts: {
/** 20 · 24 · 28 · 32 권함 */
size?: UiSize;
ariaLabel?: string;
/** 한 줄 칸 수 — 넘으면 다음 줄(없음 = 한 줄) */
columns?: number;
}): SegmentHandle {
const root = document.createElement("div");
root.className = "ui-segment";
root.setAttribute("role", "radiogroup");
if (opts.ariaLabel) root.setAttribute("aria-label", opts.ariaLabel);
setUiSize(root, opts.size ?? 28);
if (opts.columns) {
root.dataset.columns = String(opts.columns);
root.style.setProperty("--ui-segment-columns", String(opts.columns));
}
let current = opts.value;
const items = opts.options.map((o) => {
const b = document.createElement("button");
+3 -2
View File
@@ -337,9 +337,10 @@ export const KIT_PARTS: KitPart[] = [
demo: (s) => (s === "default" ? col(createKv([{ label: "연장", value: "1,080 m", big: true }, { label: "종단 경사", value: "12 %", ok: true }, { label: "최소 곡선반경", value: "12 m", ok: false }, { label: "비고", value: null }]), createKv([{ label: kitKeys("Ctrl", "Z"), value: "되돌리기" }, { label: kitKeys("휠 끌기"), value: "화면 옮기기" }, { label: kitKeys("Esc") }], { rule: true })) : null) },
{ key: "calc-row", name: "계산 줄", group: "compound", kinds: "값 줄(이름 · 값 112 · 덧칸) · 식 줄(이름 · 식 · 값) · 위 · 아래첨자 · 중요 결과 굵게", file: "ui_template_calc_row.ts",
demo: (s) => (s === "default" ? kitCalc() : null) },
{ key: "progress", name: "진행 막대", group: "compound", kinds: "막대 6 · 8 · 쌓임 막대 · 원 · 오류", file: "ui_template_progress.ts",
{ key: "progress", name: "진행 막대", group: "compound", kinds: "막대 6 · 8 · 쌓임 막대 · 원 · 원 귀퉁이(corner) · 오류", file: "ui_template_progress.ts",
demo: (s) => { if (s === "error") return markState(createProgressBar({ value: 0.6, label: "실패" }).root, s); if (s !== "default") return null; const ring = createProgressCircle({ size: 48 }); ring.set(0.4);
return col(createProgressBar({ value: 0.4, label: "40 %" }).root, createProgressBar({ value: 0.7, thick: 6 }).root, createProgressBar({ parts: [{ value: 2, tone: "soft" }, { value: 3 }, { value: 1, tone: "warn" }], label: "6 건" }).root, ring.root); } },
const corner = createProgressCircle({ size: 40, corner: true }); corner.set(0.65, "지형 65%"); const box = document.createElement("div"); box.style.cssText = "position:relative;height:72px;border:1px dashed var(--color-border);border-radius:var(--radius-inputs)"; box.append(corner.root);
return col(createProgressBar({ value: 0.4, label: "40 %" }).root, createProgressBar({ value: 0.7, thick: 6 }).root, createProgressBar({ parts: [{ value: 2, tone: "soft" }, { value: 3 }, { value: 1, tone: "warn" }], label: "6 건" }).root, ring.root, box); } },
{ key: "mini-chart", name: "작은 그래프", group: "compound", kinds: "막대 · 원 · 선", file: "ui_template_elements_chart.ts",
demo: (s) => (s === "default" ? col(createBarChart([{ label: "산림설계", value: 12 }, { label: "녹색기술", value: 7 }, { label: "그 밖 3 곳", value: 4 }]), createDonut([{ label: "임도", value: 5 }, { label: "예정노선", value: 3 }, { label: "안정성", value: 2 }]), createLineChart({ series: [{ name: "CPU", values: [20, 35, 30, 50, 42, 60], colorIndex: 0 }], height: 120 })) : null) },
{ key: "legend", name: "범례", group: "compound", kinds: "색 칩 · 선 · 점선 · 점 · 꺼짐 · 세로", file: "ui_template_flow_legend.ts",