diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index 31927ecf2..c5ddfc3ae 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -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 의 버림 · 뒤집기는 안 함 diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index 9173d9be6..06f8a033f 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -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, diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts index 32112e7ba..53ea0225f 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts @@ -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 = { 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 => new Set(network.pieces.map((piece) => piece.id)); @@ -183,8 +193,9 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { apply((net, i) => removeLine(net, i)); } + /** 사라진 구간 뺌 — 부르는 쪽(페이지 `refresh`)이 다시 그림(여기서 onSelect 를 부르면 돌고 돎). */ function sync(): void { - if (!selected) return params.onSelect(); + if (!selected) return; const ids = pieceIds(params.network()); const pieces = selected.pieces.filter((id) => ids.has(id)); selected = pieces.length @@ -195,7 +206,6 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { mark: selected.mark && ids.has(selected.mark) ? selected.mark : null, } : null; - params.onSelect(); } const pieceAt = (network: PlanNetwork, line: string, atM: number): string | null => @@ -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; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index fb126ca51..a4e0adf69 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -32,6 +32,9 @@ import { fitCanvas, screenToMetric } from "../B05_Profile/B05_Profile_UI_RouteEd import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; import { localTmFrame, tmParams } from "./logic/D03_PlanAnalysis_Tm"; +/** 도구줄 「연도 마커」 끌어 놓기 자료 꼴(585-3). */ +export const YEAR_MARK_DRAG = "application/x-aislo-year-mark"; + /** 측점 간격(m) — B 규칙 측점과 같음. */ export const PLAN_STATION_INTERVAL_M = 20; /** 겹 그림 색(그림 전용 값 `--draw-*` · 밝음 · 어두움 같음) — 선 · 그리는 중 · 경고 · 글 테두리. */ @@ -84,6 +87,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; } @@ -100,6 +106,8 @@ export interface PlanTools { view3d: boolean; /** 거리재기 켜짐(눌림). */ measure: boolean; + /** 「연도 마커」 끌 수 있음(고칠 수 있고 선 있음). */ + yearMark: boolean; } export interface PlanMap { @@ -169,6 +177,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: { @@ -182,7 +196,8 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { "Shift+누름 = 구간 더함 · 뺌", "빈 곳 오른쪽으로 끌기 = 창 안 구간 · 왼쪽으로 = 걸친 구간까지", "고른 선 위 끌기 = 꺾임점 더해 옮김", - "꺾임점 · 마크 끌기 = 옮김", + "「연도 마커」 선 위로 끌어 놓기 = 구역 나눔", + "꺾임점 · 마크 끌기 = 옮김 · 마크를 선 밖으로 = 지움(앞 구간과 합침)", "오른쪽 누름 = 꺾임점 · 마크 지움", "Delete = 고른 선 · 마크 지움", "가운데 끌기 = 옮기기", @@ -194,6 +209,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { [undoButton, redoButton], [viewButton, "rotate-ccw", "rotate-cw"], [markButton("stations", "측점"), markButton("slopes", "구간 길이 · 기울기"), measureButton], + [yearMarkButton], ], }); const toolbar = tools.root; @@ -376,6 +392,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; @@ -414,6 +454,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, diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 6ae86d710..a41c87120 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -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 { 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 { 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 { }); 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 { 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 { 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(); @@ -507,7 +497,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { select: edit.select, setScope: edit.setScope, box: edit.box, - drawYear: () => drawYear, constraint: draw.constraint, setConstraint: draw.setConstraint, mode: draw.mode, @@ -554,8 +543,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { 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("d03-plan", projectId); if (draft?.version === DRAFT_VERSION && draft.network?.lines?.length) network = draft.network; map.update({ fit: [...points, ...allVertices()], epsg }); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts index b5d51c9a4..98eaede41 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts @@ -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({ diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts index bae459d83..9ccc40037 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts @@ -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; +} diff --git a/resources/tester/test_530_3_plan_logic.py b/resources/tester/test_530_3_plan_logic.py index 6cd187ea7..c79652cb1 100644 --- a/resources/tester/test_530_3_plan_logic.py +++ b/resources/tester/test_530_3_plan_logic.py @@ -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), ] diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index b28bc08df..6e06ad974 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -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"): diff --git a/resources/tester/test_568_1_map_tools.py b/resources/tester/test_568_1_map_tools.py index 9cfd9653f..c14681ef0 100644 --- a/resources/tester/test_568_1_map_tools.py +++ b/resources/tester/test_568_1_map_tools.py @@ -55,6 +55,7 @@ def test_단추_목록_아이콘_차례(): "footprint", "measure", "station-snap", + "year-mark", ] names = _icon_names() assert all(icon in names for _, icon, _ in rows) diff --git a/resources/tester/test_585_2_plan_select.py b/resources/tester/test_585_2_plan_select.py index 5f0d28883..fc4e4c4f9 100644 --- a/resources/tester/test_585_2_plan_select.py +++ b/resources/tester/test_585_2_plan_select.py @@ -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("다른 선에 닿은 끝이 없습니다") diff --git a/ui_template/ui_template_icons.ts b/ui_template/ui_template_icons.ts index c6df08289..89db9daf4 100644 --- a/ui_template/ui_template_icons.ts +++ b/ui_template/ui_template_icons.ts @@ -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; diff --git a/ui_template/ui_template_map_tools.ts b/ui_template/ui_template_map_tools.ts index 47c7e28cd..afacf9875 100644 --- a/ui_template/ui_template_map_tools.ts +++ b/ui_template/ui_template_map_tools.ts @@ -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; export type MapToolAct = keyof typeof MAP_TOOLS;