feat(D03): 연도 구역 마커 끌어 놓기 · 선 밖으로 끌어내 지움 · 긋는 해 걷음 · 갈래 시점 지정 (PLAN 585-3 · 요구 445 A6 · 446)

- 도구줄 「연도 마커」(공용 지도 도구줄 year-mark · 깃발 아이콘)를 선 위로 끌어 놓음 = 그 자리 구간을 둘로 — 구역만 나눔 · 해 그대로
- 끄는 동안 마름모 미리보기 · 선 끝 · 다른 마커 1 m 안 · 선 밖 = 경고 색 · 못 놓음
- 놓인 마커 끌기 = 경계 옮김 · 선 밖으로 끌어냄 · 오른쪽 누름 · Delete = 지움(앞 구간과 합침) · 같은 해 양쪽 = 속 빈 마름모
- 좌측 [연도 구분] · 「긋는 해」 걷음 · 새 선 = 사업연도 시작 해 · 새 갈래 = 붙은 자리 구간 해
- 속성 판 [갈래 시점 지정] = 다른 선에 닿은 끝을 시점(No.0)으로 갈래 · 꼬리에 닿은 머리 = 한 선 · 본선 = [방향 바꾸기]
- 시험: 585-2 시험에 나눔 · 합침 · 갈래 해 · 갈래 시점 덧 · 530-3 갈래 해 · 553-4 속성 판 · 568-1 도구줄 목록 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 18:51:04 +09:00
co-authored by Claude Opus 5.5
parent e6951b5c73
commit cc909d107e
13 changed files with 259 additions and 103 deletions
+8 -60
View File
@@ -9,9 +9,9 @@
* 미리보기 = 마지막 점 → 마우스 길이(m) · 가상 종단 기울기(원지반 % · ° · + 오르막) — 제약 칸 넷:
* 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중
* 마우스에 가까운 것) · 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈).
* 긋는 해 = 새 조각 처음 해 · 긋는 중 해를 바꾸면 다음 점부터 그 해(지금 끝 자리에 연도 구분 마크).
* 연도 구분 = 선 위 누름(마크 · 뒤 조각 고름 → 페이지가 연도 고름을 엶) · 오른쪽 = 지움.
* 고르기 · 고치기(쉴 때) = `D03_PlanAnalysis_UI_Edit`.
* 긋기는 해를 안 물음(585-3) — 새 선 = 사업연도 시작 해 · 갈래 = 붙은 자리 구간 해 · 이어 긋기 = 끝 구간이 늘어남 ·
* 여러 해 = 다 그은 뒤 도구줄 「연도 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름.
* 고르기 · 고치기 · 마커(쉴 때) = `D03_PlanAnalysis_UI_Edit`.
* 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기(설정 창 단축키 표 D03 줄).
* 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`) · 꼴 다루기 = `logic/D03_PlanAnalysis_Plan` ·
* 값을 바꾸면 `commit(다음 꼴)` 한 번 = 되돌리기 한 걸음(스택 · 캐시 = 페이지).
@@ -27,7 +27,6 @@ import {
fixedLengthPoint,
fixedSlopePoint,
groundSlopePct,
lineLength,
segmentLength,
} from "./logic/D03_PlanAnalysis_Draw";
import {
@@ -37,13 +36,9 @@ import {
dropLastVertex,
insertVertex,
newLine,
normalizePieces,
removeLine,
removeMark,
reverseLine,
samePoint,
setPieceYear,
splitPiece,
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
@@ -63,7 +58,7 @@ export const DRAW_DEFAULT_LENGTH_M = 20;
export const DRAW_DEFAULT_SLOPE_PCT = 9;
export type DrawConstraint = "free" | "length" | "slope" | "both";
export type DrawMode = "idle" | "draw" | "mark";
export type DrawMode = "idle" | "draw";
export interface DrawPreview {
point: Vertex;
@@ -83,12 +78,8 @@ export interface PlanDrawParams {
network: () => PlanNetwork;
heightAt: () => (x: number, y: number) => number;
commit: (next: PlanNetwork) => void;
/** 긋는 해 — 새 조각 처음 해 · 긋는 중 바꾸면 다음 점부터. */
year: () => number;
/** 모드 · 그리는 선이 바뀜 — 페이지가 단추 · 안내를 다시. */
onMode: () => void;
/** 연도 구분 마크를 놓음 — 뒤 조각(id) 고름 · 연도 고름. */
onMark: (piece: string) => void;
}
export interface PlanDraw {
@@ -128,15 +119,9 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
size: 28,
onClick: () => setMode(mode === "draw" ? "idle" : "draw"),
});
const markButton = createButton({
label: "연도 구분",
variant: "ghost",
size: 28,
onClick: () => setMode(mode === "mark" ? "idle" : "mark"),
});
const modeRow = document.createElement("div");
modeRow.className = "d03-plan__row";
modeRow.append(drawButton, markButton);
modeRow.append(drawButton);
const kindSegment = createSegment({
options: [
{ value: "free", label: "자유" },
@@ -203,12 +188,9 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
}
function panelState(): void {
const network = params.network();
drawButton.querySelector(".ui-btn__label")!.textContent = mode === "draw" ? "긋기 끝" : "긋기";
drawButton.disabled = !editable;
markButton.disabled = !editable || !network.lines.some((line) => lineLength(line.vertices) > 0);
drawButton.setAttribute("aria-pressed", String(mode === "draw"));
markButton.setAttribute("aria-pressed", String(mode === "mark"));
panel.dataset.mode = mode;
hint.textContent = !editable
? "보기만 — 산림청 계정에서 긋습니다."
@@ -216,9 +198,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
? active === null
? "지점 · 선 끝 · 선 위를 눌러 시작합니다."
: "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기"
: mode === "mark"
? "선 위를 눌러 연도를 나눕니다 · 오른쪽 누름 = 마크 지움"
: "[긋기]로 지점 · 선에서 긋습니다 · 선을 누르면 고릅니다.";
: "[긋기]로 지점 · 선에서 긋습니다 · 선 누름 = 고름 · 「연도 마커」를 선 위로 끌면 구역을 나눕니다.";
}
fieldsState();
@@ -395,21 +375,6 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
return false;
}
/** 긋는 해가 끝 조각 해와 다르면 `fromM`(이번 점 앞 길이)에서 나눠 새 조각 = 긋는 해. */
function yearSplit(network: PlanNetwork, line: number, fromM: number): PlanNetwork {
const year = params.year();
if (!(fromM > 0)) return network;
let next = normalizePieces(network, year);
const id = next.lines[line].id;
const tail = next.pieces.filter((piece) => piece.line === id).at(-1);
if (!tail || tail.year === year) return next;
next = splitPiece(next, id, fromM);
const index = next.pieces.findIndex(
(piece) => piece.line === id && Math.abs(piece.from_m - fromM) < 1e-3,
);
return index >= 0 ? setPieceYear(next, index, year) : next;
}
function addPoint(): boolean {
if (active === null || !preview) return false;
if (preview.blocked) {
@@ -419,11 +384,9 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
const line = active;
const ended = preview.snapped;
let next = params.network();
const before = Math.round(lineLength(next.lines[line].vertices) * 1000) / 1000;
if (preview.onLine)
next = insertVertex(next, preview.onLine.line, preview.onLine.segment, preview.point);
next = yearSplit(appendVertex(next, line, preview.point), line, before);
params.commit(next);
params.commit(appendVertex(next, line, preview.point));
if (ended) {
showToast(`${preview.note ?? "붙음"} — 긋기 끝`, "success");
setMode("idle");
@@ -458,21 +421,6 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
preview = previewAt(px, py, map);
return addPoint();
}
if (mode === "mark") {
const network = params.network();
const hit = lineAt(network, px, py, map);
if (!hit) return false;
const id = network.lines[hit.line].id;
const next =
button === 2
? removeMark(network, id, hit.atM, SNAP_PX / map.pxPerMeter)
: splitPiece(network, id, hit.atM);
if (next === network) return true;
const added = next.pieces.find((piece) => !network.pieces.some((p) => p.id === piece.id));
params.commit(next);
if (added) params.onMark(added.id);
return true;
}
return false;
},
hover(px, py, map) {
@@ -500,7 +448,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
setMode("idle");
return true;
}
if (mode !== "idle" && event.key === "Escape") {
if (mode === "draw" && event.key === "Escape") {
if (mode === "draw" && strokeStart && strokeStart !== params.network())
params.commit(strokeStart);
active = null; // 되돌린 꼴이라 stop 의 버림 · 뒤집기는 안 함
@@ -186,24 +186,43 @@ function drawPieces(context: CanvasRenderingContext2D, map: ScreenMap, network:
const vertices = network.lines.find((line) => line.id === piece.line)?.vertices;
if (!vertices || vertices.length < 2) return;
if (piece.from_m > 0) {
const [sx, sy] = map.toScreen(pointAtDistance(vertices, piece.from_m));
context.beginPath();
context.moveTo(sx, sy - 8);
context.lineTo(sx + 6, sy);
context.lineTo(sx, sy + 8);
context.lineTo(sx - 6, sy);
context.closePath();
context.fillStyle = colors.active;
context.fill();
context.lineWidth = 1.5;
context.strokeStyle = "#ffffff";
context.stroke();
// 앞 구간과 같은 해 = 구역만 나눔(속 빈 마름모 · 585-3)
const before = network.pieces.find(
(p) => p.line === piece.line && Math.abs(p.to_m - piece.from_m) < 1e-3,
);
const hollow = before?.year === piece.year;
paintMark(
context,
map.toScreen(pointAtDistance(vertices, piece.from_m)),
colors.active,
hollow,
);
}
const [mx, my] = map.toScreen(pointAtDistance(vertices, (piece.from_m + piece.to_m) / 2));
planLabel(context, map, mx, my + 16, `${piece.year}년`, "bold 12px");
});
}
/** 연도 구역 마커 마름모 — 속 빈 = 같은 해 구역 나눔. */
export function paintMark(
context: CanvasRenderingContext2D,
[sx, sy]: [number, number],
color: string,
hollow = false,
): void {
context.beginPath();
context.moveTo(sx, sy - 8);
context.lineTo(sx + 6, sy);
context.lineTo(sx, sy + 8);
context.lineTo(sx - 6, sy);
context.closePath();
context.fillStyle = hollow ? "#ffffff" : color;
context.fill();
context.lineWidth = hollow ? 2.5 : 1.5;
context.strokeStyle = hollow ? color : "#ffffff";
context.stroke();
}
/** 미리보기 — 점선 · 둘 다(양각기) 원 · 후보 · 글(거리 · 가상 종단 기울기 % · °). */
export function paintPreview(
context: CanvasRenderingContext2D,
+63 -2
View File
@@ -9,6 +9,8 @@
* 고른 선 위 끌기 = 꺾임점 더해 옮김(누름만은 넓힘) · 꺾임점 끌기 = 옮김(붙은 갈래 첫 점 같이) ·
* 마크 누름 = 뒤 구간 고름 + 마크 밝힘 · 끌기 = 연도 구분 옮김 · 오른쪽 누름 = 꺾임점 · 마크 지움 ·
* Delete = 고른 마크 지움 · 고른 구간이 선 통째면 그 선 지움.
* 연도 구역 마커(585-3) = 도구줄 「연도 마커」를 선 위로 끌어 놓음 = 그 자리 구간을 둘로(해 그대로 · 구역만 나눔 ·
* 해는 고른 뒤 속성 판) · 끄는 동안 마름모 미리보기(못 놓음 = 경고 색) · 놓인 마크를 선 밖으로 끌어냄 = 지움(합침).
* 잠금 = 지점에 놓인 점(1단계 몫) · 갈래 첫 점(붙은 선 점을 끌면 같이 감).
* 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 · 넓이 = 화면 기억(저장 안 함).
* 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 `select` · `setScope` · 동작 셋을 부름).
@@ -32,15 +34,19 @@ import {
removeVertex,
reverseLine,
samePoint,
splitPiece,
branchFromEnd,
type PlanNetwork,
type Vertex,
type ViewBox,
} from "./logic/D03_PlanAnalysis_Plan";
import { lineAt, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
import { lineAt, paintMark, planLabel, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
/** 끌기로 볼 마우스 움직임(px) — 이보다 적으면 누름. */
const DRAG_PX = 4;
/** 마크를 선에서 이만큼(px) 넘게 끌어내면 지움. */
const MARK_OFF_PX = 3 * SNAP_PX;
export type PlanScope = "piece" | "line" | "year";
const NEXT_SCOPE: Record<PlanScope, PlanScope> = { piece: "line", line: "year", year: "piece" };
@@ -79,6 +85,8 @@ export interface PlanEdit {
flipLine: () => void;
dropLine: () => void;
dropMark: () => void;
/** 갈래 시점 지정 — 다른 선에 닿은 끝을 시점으로 그 선의 갈래(585-3). */
branchLine: () => void;
/** 고른 구간이 모두 한 선이면 그 선 차례(아니면 −1). */
lineIndex: () => number;
/** 키 — 받았으면 참(Delete · Esc). */
@@ -107,6 +115,8 @@ type Drag =
export function createPlanEdit(params: PlanEditParams): PlanEdit {
let selected: PlanSelection | null = null;
let drag: Drag | null = null;
/** 「연도 마커」 끄는 중 미리보기 — 놓일 자리 · 놓을 수 있음 · 글. */
let markDrop: { point: Vertex; ok: boolean; text: string } | null = null;
const pieceIds = (network: PlanNetwork): Set<string> =>
new Set(network.pieces.map((piece) => piece.id));
@@ -242,6 +252,20 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
: null;
}
/** 「연도 마커」를 이 자리에 놓으면 — 다음 꼴 · 새 뒤 구간 id(못 놓음 = null). */
function markPlace(
px: number,
py: number,
map: ScreenMap,
): { point: Vertex; next: PlanNetwork | null; piece: string | null } {
const network = params.network();
const hit = lineAt(network, px, py, map);
if (!hit) return { point: map.toMetric(px, py), next: null, piece: null };
const next = splitPiece(network, network.lines[hit.line].id, hit.atM);
const added = next.pieces.find((piece) => !network.pieces.some((p) => p.id === piece.id));
return { point: hit.point, next: added ? next : null, piece: added?.id ?? null };
}
/** 구간 누름 — Shift = 더함 · 뺌 · 이미 고른 곳 = 넓힘(놓을 때) · 아니면 그 구간. */
function pressPiece(
piece: string,
@@ -317,6 +341,11 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
context.strokeStyle = colors.active;
context.stroke();
}
if (markDrop) {
const at = map.toScreen(markDrop.point);
paintMark(context, at, markDrop.ok ? colors.active : colors.warn);
planLabel(context, map, at[0], at[1] - 20, markDrop.text, "bold 12px");
}
// 끌어 고르기 상자 — 보이는 화면 네모를 그림 px 로 되돌려 그림
if (drag?.kind === "box") {
const { box } = drag;
@@ -421,10 +450,41 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
} else {
const piece = drag.before.pieces[drag.index];
const vertices = drag.before.lines.find((line) => line.id === piece.line)!.vertices;
drag.last = moveMark(drag.before, drag.index, distanceAlong(vertices, cursor));
const atM = distanceAlong(vertices, cursor);
const off = screenGap(map, pointAtDistance(vertices, atM), px, py) > MARK_OFF_PX;
drag.last = off
? removeMark(drag.before, piece.line, piece.from_m, 1e-3)
: moveMark(drag.before, drag.index, atM);
}
params.show(drag.last);
},
dragOver(px, py, map) {
if (!params.enabled() || !params.editable()) return false;
const place = markPlace(px, py, map);
markDrop = {
point: place.point,
ok: !!place.next,
text: place.next
? "여기서 구역 나눔"
: lineAt(params.network(), px, py, map)
? "선 끝 · 다른 마커 1 m 안 — 못 놓음"
: "선 위에 놓으세요",
};
return true;
},
drop(px, py, map) {
markDrop = null;
if (!params.enabled() || !params.editable()) return false;
const place = markPlace(px, py, map);
if (!place.next || !place.piece)
return (void showToast("선 위(끝 · 다른 마커 1 m 밖)에 놓으세요.", "info"), true);
params.commit(place.next);
select([place.piece], place.piece);
return true;
},
leave() {
markDrop = null;
},
release(map) {
const done = drag;
drag = null;
@@ -463,6 +523,7 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
flipLine: () => apply((network, index) => reverseLine(network, index)),
dropLine: () => apply((network, index) => removeLine(network, index)),
dropMark,
branchLine: () => apply((network, index) => branchFromEnd(network, index)),
lineIndex,
key(event) {
if (!selected || !params.enabled()) return false;
+42 -1
View File
@@ -28,6 +28,9 @@ import { createMapRotation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Rotat
import { fitCanvas, screenToMetric } from "../B05_Profile/B05_Profile_UI_RouteEdit_View";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
/** 도구줄 「연도 마커」 끌어 놓기 자료 꼴(585-3). */
export const YEAR_MARK_DRAG = "application/x-aislo-year-mark";
/** 측점 간격(m) — B 규칙 측점과 같음. */
export const PLAN_STATION_INTERVAL_M = 20;
/** 겹 그림 색(그림 전용 값 `--draw-*` · 밝음 · 어두움 같음) — 선 · 그리는 중 · 경고 · 글 테두리. */
@@ -78,6 +81,9 @@ export interface PlanMapLayer {
press?: (px: number, py: number, map: ScreenMap, button: 0 | 2, shift: boolean) => boolean;
hover?: (px: number, py: number, map: ScreenMap) => void;
release?: (map: ScreenMap) => void;
/** 「연도 마커」를 끌고 지나감(참 = 받을 겹) · 놓음(참 = 받음) — 585-3. */
dragOver?: (px: number, py: number, map: ScreenMap) => boolean;
drop?: (px: number, py: number, map: ScreenMap) => boolean;
leave?: () => void;
}
@@ -94,6 +100,8 @@ export interface PlanTools {
view3d: boolean;
/** 거리재기 켜짐(눌림). */
measure: boolean;
/** 「연도 마커」 끌 수 있음(고칠 수 있고 선 있음). */
yearMark: boolean;
}
export interface PlanMap {
@@ -161,6 +169,12 @@ 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: {
@@ -174,7 +188,8 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
"Shift+누름 = 구간 더함 · 뺌",
"빈 곳 오른쪽으로 끌기 = 창 안 구간 · 왼쪽으로 = 걸친 구간까지",
"고른 선 위 끌기 = 꺾임점 더해 옮김",
"꺾임점 · 마크 끌기 = 옮김",
"「연도 마커」 선 위로 끌어 놓기 = 구역 나눔",
"꺾임점 · 마크 끌기 = 옮김 · 마크를 선 밖으로 = 지움(앞 구간과 합침)",
"오른쪽 누름 = 꺾임점 · 마크 지움",
"Delete = 고른 선 · 마크 지움",
"가운데 끌기 = 옮기기",
@@ -186,6 +201,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
[undoButton, redoButton],
[viewButton, "rotate-ccw", "rotate-cw"],
[markButton("stations", "측점"), markButton("slopes", "구간 길이 · 기울기"), measureButton],
[yearMarkButton],
],
});
const toolbar = tools.root;
@@ -340,6 +356,30 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
for (const layer of layers) layer.leave?.();
drawTop();
});
const markDrag = (event: DragEvent): boolean =>
!!meta && !!event.dataTransfer?.types.includes(YEAR_MARK_DRAG);
canvas.addEventListener("dragover", (event) => {
if (!markDrag(event)) return;
const [px, py] = at(event);
const map = screenMap();
if (layers.some((layer) => layer.dragOver?.(px, py, map))) {
event.preventDefault();
event.dataTransfer!.dropEffect = "copy";
}
drawTop();
});
canvas.addEventListener("drop", (event) => {
if (!markDrag(event)) return;
event.preventDefault();
const [px, py] = at(event);
const map = screenMap();
layers.some((layer) => layer.drop?.(px, py, map));
drawTop();
});
canvas.addEventListener("dragleave", () => {
for (const layer of layers) layer.leave?.();
drawTop();
});
canvas.addEventListener("contextmenu", (event) => {
event.preventDefault();
if (!meta) return;
@@ -378,6 +418,7 @@ 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,
+7 -20
View File
@@ -4,7 +4,7 @@
*
* 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 연도 구분) + 도구 여섯.
* 좌 = 지형(들어올 때 받은 범위가 지점을 못 덮으면 바로 받기 · [범위 밖 더 받기]는 지점 · 선이
* 받은 범위 밖일 때만) · 그리기([긋기] · [연도 구분] · 제약 칸 · 긋는 해) ·
* 받은 범위 밖일 때만) · 그리기([긋기] · 제약 칸 · 연도 = 도구줄 「연도 마커」 끌어 놓기 585-3) ·
* 구역 · 연도(해 · 선 · 구간 줄 = 고르기 · 고른 줄 밝힘) · [저장] [초기화].
* 고른 구간 속성(연도 · 넓이 · 방향 · 지움) = 지도 위 속성 판 `D03_PlanAnalysis_UI_Props`(585-2).
* 지점 = D02 지점 목록(핀 · 늘 맨 위 · 585-1) · 고르기 · 고치기 = `D03_PlanAnalysis_UI_Edit`.
@@ -18,7 +18,7 @@
import "./D03_PlanAnalysis_UI_Style.css";
import { ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { createInputField } from "@ui/ui_template_input";
import { editMap } from "@ui/ui_template_kakao_map";
import { askConfirm, openModal } from "@ui/ui_template_modal";
import { createProgressBar } from "@ui/ui_template_progress";
@@ -85,8 +85,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
let editable = false;
let points: Vertex[] = []; // 기준 지점(D02 · 585-1)
let epsg = 0;
let yearStart = new Date().getFullYear();
let drawYear = yearStart; // 긋는 해 — 새 조각 처음 해
let yearStart = new Date().getFullYear(); // 새 선 처음 해
let network: PlanNetwork = emptyNetwork();
let savedJson = JSON.stringify(network); // 못 읽은 채 떠남 = 고침 없음
let undoStack: PlanNetwork[] = [];
@@ -104,19 +103,17 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
network: () => network,
heightAt: () => z,
commit,
year: () => drawYear,
onMode: () => {
if (draw.mode() !== "idle") setMeasure(false);
refresh();
},
onMark: (piece) => edit.select([piece]),
});
const edit = createPlanEdit({
network: () => network,
base: () => points,
commit,
show: (next) => {
network = normalizePieces(next, drawYear);
network = normalizePieces(next, yearStart);
map.redraw();
},
enabled: () => draw.mode() === "idle" && !measuring,
@@ -166,16 +163,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
});
const terrainCard = createPanelCard("지형", false);
terrainCard.append(terrainText, bar.root, extentButton, moreButton);
const drawYearPick = createSelectField({
label: "긋는 해",
options: [],
size: 24,
ariaLabel: "긋는 해",
onChange: (value) => (drawYear = Number(value)),
});
drawYearPick.select.dataset.role = "draw-year";
const drawCard = createPanelCard("그리기", false);
drawCard.append(draw.panel, drawYearPick.root);
drawCard.append(draw.panel);
const pieceCard = createPanelCard("구역 · 연도", false);
pieceCard.append(props.list);
const saveButton = createButton({ label: "저장", variant: "filled", onClick: () => void save() });
@@ -212,7 +201,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
function commit(next: PlanNetwork): void {
undoStack = [...undoStack.slice(1 - UNDO_MAX), network];
redoStack = [];
network = normalizePieces(next, drawYear);
network = normalizePieces(next, yearStart);
keepDraft();
refresh();
}
@@ -265,6 +254,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
redo: editable && redoStack.length > 0,
view3d: !!surface && network.lines.some((line) => line.vertices.length > 1),
measure: measuring,
yearMark: editable && network.lines.some((line) => line.vertices.length > 1),
});
map.redraw();
props.render();
@@ -506,7 +496,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
select: edit.select,
setScope: edit.setScope,
box: edit.box,
drawYear: () => drawYear,
constraint: draw.constraint,
setConstraint: draw.setConstraint,
mode: draw.mode,
@@ -553,8 +542,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
points = base.points.map((p): Vertex => [p.x, p.y]);
epsg = base.epsg;
takeServer(view);
drawYear = yearStart;
drawYearPick.setOptions(props.yearOptions(), String(drawYear));
const draft = readState<Draft>("d03-plan", projectId);
if (draft?.version === DRAFT_VERSION && draft.network?.lines?.length) network = draft.network;
map.update({ fit: [...points, ...allVertices()] });
+13 -2
View File
@@ -5,7 +5,9 @@
* 속성 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 —
* 머리 「구간 N · 선 M · L m」 · ✕ = 풂 · 넓이 [구간][선][같은 해] · 연도(모두 같으면 그 해 · 다르면 「여러 해」 →
* 고르면 고른 구간 모두 그 해 = 되돌리기 한 걸음) · 고른 구간 줄(누름 = 그 구간만) ·
* [방향 바꾸기] [선 지움](선 하나일 때) · [마크 지움](마크 고름) · 보기만 계정 = 값만.
* [방향 바꾸기] [갈래 시점 지정] [선 지움](선 하나일 때) · [마크 지움](마크 고름) · 보기만 계정 = 값만.
* 측점 방향(585-3 · 요구 446 3) = 사용자가 정함 — 본선 = [방향 바꾸기] · 다른 선에 닿은 끝 = [갈래 시점 지정]
* (그 점이 시점 · 꼬리에 닿은 머리 = 한 선으로 이음) · 자동 판정 없음.
* 좌측 목록 = 보기 + 고르기만(해 고침은 속성 판 한 곳) — 해 줄(같은 해) · 선 머리 줄(선) · 구간 줄(구간).
* ========================================================================== */
@@ -141,7 +143,16 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
onClick: edit.dropLine,
});
drop.dataset.role = "props-drop";
actions.append(flip, drop);
const branch = createButton({
label: "갈래 시점 지정",
variant: "ghost",
size: 24,
onClick: edit.branchLine,
});
branch.title = "다른 선에 닿은 끝을 시점(No.0)으로 · 그 선의 갈래로 붙입니다.";
branch.hidden = !!network.lines[index].branch;
branch.dataset.role = "props-branch";
actions.append(flip, branch, drop);
}
if (editable && picked.mark) {
const dropMark = createButton({
@@ -1,7 +1,8 @@
/* =============================================================================
* D03_PlanAnalysis_Plan.ts
* D 예정노선 2단계 손 긋기 꼴(PLAN 562-3 · 580) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기 ·
* 선 뒤집기 · 잇기 · 꺾임점 옮김 · 지움 · 선 지움 · 선 통째 연도 · 마크 옮김 · 여러 조각 연도 · 끌어 고르기 판정(585-2).
* 선 뒤집기 · 잇기 · 꺾임점 옮김 · 지움 · 선 지움 · 선 통째 연도 · 마크 옮김 · 여러 조각 연도 · 끌어 고르기 판정(585-2) ·
* 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3).
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
* [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음.
* ========================================================================== */
@@ -70,6 +71,19 @@ export function newLine(network: PlanNetwork, point: Vertex): PlanNetwork {
return { ...network, lines: [...network.lines, line] };
}
/** 새 갈래 처음 해(585-3) = 붙은 자리 구간 해 · 붙은 자리 못 찾음 = null. */
function branchYear(network: PlanNetwork, line: PlanLine): number | null {
const parent = network.lines.find((other) => other.id === line.branch?.line);
if (!parent || !line.branch) return null;
const atM = lineLength(parent.vertices.slice(0, line.branch.vertex + 1));
return (
network.pieces.find(
(piece) =>
piece.line === parent.id && atM >= piece.from_m - SAME_M && atM <= piece.to_m + SAME_M,
)?.year ?? null
);
}
/** 선마다 조각이 0 ~ 길이를 빈틈없이 덮게 맞춤 — 경계 = 조각 시작(0 · 끝 근처 빼고) · 연도 = 그 자리 조각 ·
* 조각 없는 선 = 한 조각(`year`). 길이 0 선은 조각 없음. */
export function normalizePieces(network: PlanNetwork, year: number): PlanNetwork {
@@ -83,7 +97,10 @@ export function normalizePieces(network: PlanNetwork, year: number): PlanNetwork
const starts = own
.filter((piece) => piece.from_m > MARK_GAP_M / 2 && piece.from_m < length - MARK_GAP_M / 2)
.map((piece) => ({ id: piece.id, from: round3(piece.from_m), year: piece.year }));
const head = own.find((piece) => piece.from_m <= MARK_GAP_M / 2) ?? { id: "", year };
const head = own.find((piece) => piece.from_m <= MARK_GAP_M / 2) ?? {
id: "",
year: own.length ? year : (branchYear(network, line) ?? year),
};
const cuts = [{ id: head.id, from: 0, year: head.year }, ...starts];
cuts.forEach((cut, i) =>
pieces.push({
@@ -573,3 +590,27 @@ export function piecesInBox(
})
.map((piece) => piece.id);
}
/** 갈래 시점 지정(585-3) — 본선 한 끝이 다른 선 꺾임점에 닿았으면 그 점을 시점(No.0)으로 그 선의 갈래로 ·
* 머리가 다른 선 꼬리면 한 선으로 이음(연속 구간 = 측점 차례). 닿은 끝 없음 · 이미 갈래 = 까닭 글. */
export function branchFromEnd(network: PlanNetwork, line: number): PlanNetwork | string {
const entry = network.lines[line];
if (!entry || entry.vertices.length < 2) return "선을 고르세요.";
if (entry.branch) return "이미 갈래입니다 — 갈라지는 점이 시점입니다.";
const own = family(network, entry.id);
const touches = (point: Vertex): boolean =>
network.lines.some(
(other) => !own.has(other.id) && other.vertices.some((v) => samePoint(v, point)),
);
if (touches(entry.vertices[0])) {
const next = attachHead(network, line);
if (next !== network) return next;
}
if (!touches(entry.vertices[entry.vertices.length - 1]))
return "다른 선에 닿은 끝이 없습니다 — 끝을 다른 선 위에 놓고 지정하세요.";
const turned = reverseLine(network, line);
if (typeof turned === "string") return turned;
const index = turned.lines.findIndex((other) => other.id === entry.id);
const next = attachHead(turned, index);
return next === turned ? "다른 선에 닿은 끝이 없습니다." : next;
}
+3 -2
View File
@@ -169,7 +169,8 @@ def test_branch_order_and_guard(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
branch = got["branched"]["lines"][1]
assert branch["branch"] == {"line": "main", "vertex": 1} and branch["vertices"][0] == [100, 0]
assert _spans(got["branched"], branch["id"]) == [(0, 60, 2026)]
# 585-3 갈래 처음 해 = 붙은 자리(본선 100 m = 2027 구간) 해
assert _spans(got["branched"], branch["id"]) == [(0, 60, 2027)]
# 본선 앞 구간에 꺾임점을 끼우면 갈래가 붙은 차례가 한 칸 밀림
assert got["inserted"]["lines"][0]["vertices"][1] == [40, 0]
assert got["inserted"]["lines"][1]["branch"]["vertex"] == 2
@@ -193,7 +194,7 @@ def test_server_accepts_screen_body(tmp_path: Path) -> None:
assert [(p["line"], p["from_m"], p["to_m"], p["year"]) for p in derived["pieces"]] == [
("main", 0.0, 50.0, 2026),
("main", 50.0, 200.0, 2027),
("branch-1", 0.0, 60.0, 2026),
("branch-1", 0.0, 60.0, 2027),
]
+3 -1
View File
@@ -79,7 +79,9 @@ def test_d03_parts():
], keys
assert ".d03-plan__layers" in css and ".d03-plan__legend" in css
assert "createSegment({" in draw and draw.count("createInputField(") == 2
assert "createSelectField(" in ts # 조각 연도
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
assert 'createElement("select")' not in three and "createSelectField" in three
assert "createCheckbox" not in three and "buildCorridor" not in three # 예상 코리도 걷음
for dead in (".d03-plan__bar", ".d03-plan__mode", "is-picked"):
+1
View File
@@ -55,6 +55,7 @@ def test_단추_목록_아이콘_차례():
"footprint",
"measure",
"station-snap",
"year-mark",
]
names = _icon_names()
assert all(icon in names for _, icon, _ in rows)
+42 -1
View File
@@ -1,4 +1,5 @@
"""585-2 예정노선 끌어 고르기 판정 · 여러 구간 연도(요구 445 A4 · A5) — TS `logic/D03_PlanAnalysis_Plan.ts`.
"""585-2 · 585-3 예정노선 끌어 고르기 판정 · 여러 구간 연도 · 연도 구역 마커 나눔 · 합침 · 갈래 시점 지정
(요구 445 A4~A6 · 446) — TS `logic/D03_PlanAnalysis_Plan.ts`.
창(오른쪽으로) = 구간 꺾은선 점이 모두 상자 안 · 걸침(왼쪽으로) = 점 하나라도 안 또는 토막이 상자 변과 만남 ·
돌린 화면도 보이는 px 로 셈 · 여러 구간 연도 한 번에.
@@ -42,7 +43,34 @@ writeFileSync(process.argv[2], JSON.stringify({
all: pick(box(-10, -110, 310, 10), false),
rotated: pick(box(-110, -110, -40, -90), false, turned),
years: plan.setPiecesYear(n, ["p1", "p3"], 2028).pieces.map((p) => [p.id, p.year]),
...marks(),
}));
/** 585-3 — 마커 = 구역 나누기만(해 그대로) · 지움 = 앞 구간이 먹음 · 갈래 처음 해 · 갈래 시점 지정. */
function marks() {
const span = (net) => net.pieces.filter((p) => p.line === "main").map((p) => [p.id, p.from_m, p.to_m, p.year]);
let m = plan.newLine(plan.emptyNetwork(), [0, 0]);
m = plan.normalizePieces(plan.appendVertex(m, 0, [100, 0]), Y);
const cut = plan.normalizePieces(plan.splitPiece(m, "main", 40), Y);
const edge = plan.splitPiece(cut, "main", 40.5); // 다른 마커 1 m 안 = 그대로
const yeared = plan.setPiecesYear(cut, ["p2"], 2028);
const merged = plan.normalizePieces(plan.removeMark(yeared, "main", 40, 1e-3), Y);
// 갈래 처음 해 = 붙은 자리(60 m · p2 = 2028) 구간 해
let b = plan.normalizePieces(plan.insertVertex(yeared, 0, 0, [60, 0]), Y);
b = plan.normalizePieces(plan.appendVertex(plan.addBranch(b, 0, 1), 1, [60, 50]), Y);
// 갈래 시점 지정 — (60,90) 에서 그어 (60,0) 꺾임점에 닿은 본선 → 닿은 끝이 시점인 갈래
let t = plan.normalizePieces(plan.insertVertex(m, 0, 0, [60, 0]), Y);
t = plan.normalizePieces(plan.appendVertex(plan.newLine(t, [60, 90]), 1, [60, 0]), Y);
const branched = plan.branchFromEnd(t, 1);
const lone = plan.normalizePieces(plan.appendVertex(plan.newLine(m, [0, 50]), 1, [30, 80]), Y);
return {
cut: span(cut), edgeSame: edge === cut, merged: span(merged),
branchYear: b.pieces.filter((p) => p.line !== "main").map((p) => p.year),
branched: typeof branched === "string" ? branched : branched.lines.map((l) => [l.id, l.vertices[0], l.branch]),
again: typeof branched === "string" ? null : plan.branchFromEnd(branched, 1),
lone: plan.branchFromEnd(lone, 1),
};
}
"""
@@ -88,3 +116,16 @@ def test_여러_구간_연도(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
years = dict(got["years"])
assert years["p1"] == years["p3"] == 2028 and years["p2"] == 2026
def test_마커_구역_나눔_합침_갈래(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
assert got["cut"] == [["p1", 0, 40, 2026], ["p2", 40, 100, 2026]] # 구역만 나눔 · 해 그대로
assert got["edgeSame"] # 다른 마커 1 m 안 = 안 놓음
assert got["merged"] == [["p1", 0, 100, 2026]] # 지움 = 앞 구간 해 · id 가 먹음
assert got["branchYear"] == [2028] # 갈래 처음 해 = 붙은 자리 구간 해
lines = {line_id: (head, branch) for line_id, head, branch in got["branched"]}
head, branch = lines[next(k for k in lines if k != "main")]
assert head == [60, 0] and branch == {"line": "main", "vertex": 1} # 닿은 끝 = 시점
assert got["again"] == "이미 갈래입니다 — 갈라지는 점이 시점입니다."
assert got["lone"].startswith("다른 선에 닿은 끝이 없습니다")
+2
View File
@@ -151,6 +151,8 @@ const PATHS = {
// 584-3 — 지도 겹(산사태취약지역)
alertOctagon:
"M12.802 2.165l5.575 2.389c.48 .206 .863 .589 1.07 1.07l2.388 5.574c.22 .512 .22 1.092 0 1.604l-2.389 5.575c-.206 .48 -.589 .863 -1.07 1.07l-5.574 2.388c-.512 .22 -1.092 .22 -1.604 0l-5.575 -2.389a2.036 2.036 0 0 1 -1.07 -1.07l-2.388 -5.574a2.036 2.036 0 0 1 0 -1.604l2.389 -5.575c.206 -.48 .589 -.863 1.07 -1.07l5.574 -2.388a2.036 2.036 0 0 1 1.604 0z|M12 8v4|M12 16h.01",
// 585-3 — 연도 구역 마커(D03 도구줄 끌어 놓기)
flag: "M5 5a5 5 0 0 1 7 0a5 5 0 0 0 7 0v9a5 5 0 0 1 -7 0a5 5 0 0 0 -7 0v-9z|M5 21v-7",
} as const;
export type IconName = keyof typeof PATHS;
+1
View File
@@ -31,6 +31,7 @@ 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;