diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts index 7ac980f1e..32112e7ba 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts @@ -1,17 +1,21 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Edit.ts - * D03 고르기 · 고치기 겹(PLAN 580 · 요구서 440 3 · 6 · 7) — 쉴 때(긋기 · 연도 구분 · 재기 아님)만. + * D03 고르기 · 고치기 겹(PLAN 580 · 585-2 · 요구서 440 3 · 6 · 7 · 445 A4 · A5) — 쉴 때(긋기 · 재기 아님)만. * - * 선 누름 = 그 선 · 조각 고름(굵게 · 좌측 「구역 · 연도」 그 줄 밝힘) · 빈 곳 = 고름 풂. - * 고른 선 위 누름 = 꺾임점 더함(그대로 끌면 옮김) · 꺾임점 끌기 = 옮김(붙은 갈래 첫 점 같이) · - * 마크 끌기 = 연도 구분 옮김 · 오른쪽 누름 = 꺾임점 · 마크 지움 · Delete = 고른 선 지움. + * 고름 = 구간(조각) 묶음 + 넓이(구간 · 선 · 같은 해) — 선 누름 = 그 구간 · 이미 고른 곳 다시 누름 = 넓이 한 단 위 + * (구간 → 선 → 같은 해 → 구간) · Shift+누름 = 구간 더함 · 뺌 · 빈 곳 누름 · Esc = 풂. + * 빈 곳 왼쪽 끌기 = 끌어 고르기(오토캐드 꼴 · 보이는 화면 가로 방향) — 오른쪽으로 = 창(실선 · 다 든 구간만) · + * 왼쪽으로 = 걸침(점선 · 걸친 구간까지) · Shift = 지금 고름에 더함. + * 고른 선 위 끌기 = 꺾임점 더해 옮김(누름만은 넓힘) · 꺾임점 끌기 = 옮김(붙은 갈래 첫 점 같이) · + * 마크 누름 = 뒤 구간 고름 + 마크 밝힘 · 끌기 = 연도 구분 옮김 · 오른쪽 누름 = 꺾임점 · 마크 지움 · + * Delete = 고른 마크 지움 · 고른 구간이 선 통째면 그 선 지움. * 잠금 = 지점에 놓인 점(1단계 몫) · 갈래 첫 점(붙은 선 점을 끌면 같이 감). - * 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음. - * 좌측 「고른 선」 칸 = 이름 · 길이 · [방향 바꾸기](측점 No.0 쪽 바꿈) · [선 지움]. + * 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 · 넓이 = 화면 기억(저장 안 함). + * 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 `select` · `setScope` · 동작 셋을 부름). * ========================================================================== */ -import { createButton, showToast } from "@ui/ui_template_elements"; -import { createText } from "@ui/ui_template_text"; +import { showToast } from "@ui/ui_template_elements"; +import { themeColor } from "@ui/ui_template_palette"; import { distanceAlong, lineLength, @@ -20,9 +24,9 @@ import { } from "./logic/D03_PlanAnalysis_Draw"; import { insertVertex, - lineNames, moveMark, moveVertex, + piecesInBox, removeLine, removeMark, removeVertex, @@ -30,12 +34,20 @@ import { samePoint, type PlanNetwork, type Vertex, + type ViewBox, } from "./logic/D03_PlanAnalysis_Plan"; import { lineAt, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint"; import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map"; +/** 끌기로 볼 마우스 움직임(px) — 이보다 적으면 누름. */ +const DRAG_PX = 4; + +export type PlanScope = "piece" | "line" | "year"; +const NEXT_SCOPE: Record = { piece: "line", line: "year", year: "piece" }; + export interface PlanEditParams { network: () => PlanNetwork; + /** 기준 지점(D02 · 585-1). */ base: () => Vertex[]; commit: (next: PlanNetwork) => void; /** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */ @@ -44,94 +56,146 @@ export interface PlanEditParams { enabled: () => boolean; /** 고치기 됨(산림청). */ editable: () => boolean; - /** 고름이 바뀜 — 페이지가 좌측 줄 밝힘. */ + /** 고름이 바뀜 — 페이지가 좌측 줄 밝힘 · 속성 판. */ onSelect: () => void; } +/** 고름 — 구간 id 묶음 · 넓이 · 넓힘 씨(넓이를 바꿀 때 기준 구간) · 고른 마크(그 마크가 여는 구간 id). */ export interface PlanSelection { - line: string; - piece: string | null; + pieces: string[]; + scope: PlanScope; + seed: string | null; + mark: string | null; } export interface PlanEdit { layer: PlanMapLayer; - /** 좌측 「고른 선」 칸. */ - panel: HTMLElement; selected: () => PlanSelection | null; - select: (line: string | null, piece?: string | null) => void; + /** 구간 id 묶음 고름(빈 묶음 = 풂) · 넓이 = 구간. */ + select: (pieces: string[], mark?: string | null) => void; + /** 씨 구간 기준 넓이 바꿈(구간 · 선 · 같은 해). */ + setScope: (scope: PlanScope) => void; + /** 고른 구간이 든 선 하나 — 방향 바꾸기 · 지움(선 둘 이상 · 없음 = 까닭 글). */ + flipLine: () => void; + dropLine: () => void; + dropMark: () => void; + /** 고른 구간이 모두 한 선이면 그 선 차례(아니면 −1). */ + lineIndex: () => number; /** 키 — 받았으면 참(Delete · Esc). */ key: (event: KeyboardEvent) => boolean; - /** 되돌리기 · 저장 뒤 — 고른 선이 사라졌으면 풂 · 칸 다시. */ + /** 되돌리기 · 저장 뒤 — 사라진 구간은 뺌 · 다 사라지면 풂. */ sync: () => void; + /** 검증 창구 — 지금 끄는 상자(보이는 px · 방향). */ + box: () => (ViewBox & { crossing: boolean }) | null; } type Drag = | { kind: "vertex"; line: number; vertex: number; before: PlanNetwork; last: PlanNetwork | null } | { kind: "mark"; index: number; before: PlanNetwork; last: PlanNetwork | null } - | { kind: "locked"; note: string; told: boolean }; + | { kind: "locked"; note: string; told: boolean } + /** 고른 구간 다시 누름 — 놓으면 넓힘 · 끌면(고칠 수 있을 때) 꺾임점 더해 옮김. */ + | { + kind: "grow"; + piece: string; + x: number; + y: number; + hit: { line: number; segment: number; point: Vertex } | null; + } + /** 빈 곳 끌기 — 보이는 px 시작 · 지금. */ + | { kind: "box"; box: ViewBox; shift: boolean }; export function createPlanEdit(params: PlanEditParams): PlanEdit { let selected: PlanSelection | null = null; let drag: Drag | null = null; - const info = createText("", "muted"); - const flipButton = createButton({ - label: "방향 바꾸기", - variant: "ghost", - size: 24, - onClick: () => apply((network, index) => reverseLine(network, index)), - }); - flipButton.title = "측점 No.0 쪽(시점 쪽)을 반대 끝으로 바꿉니다."; - const dropButton = createButton({ - label: "선 지움", - variant: "ghost", - size: 24, - onClick: () => apply((network, index) => removeLine(network, index)), - }); - const row = document.createElement("div"); - row.className = "d03-plan__row"; - row.append(flipButton, dropButton); - const panel = document.createElement("div"); - panel.className = "d03-plan__pick"; - panel.dataset.role = "plan-pick"; - panel.append(info, row); + const pieceIds = (network: PlanNetwork): Set => + new Set(network.pieces.map((piece) => piece.id)); - const indexOf = (network: PlanNetwork): number => - selected ? network.lines.findIndex((line) => line.id === selected!.line) : -1; + /** 씨 구간 → 넓이만큼 구간 묶음. */ + function expand(seed: string, scope: PlanScope): string[] { + const network = params.network(); + const piece = network.pieces.find((p) => p.id === seed); + if (!piece) return []; + if (scope === "piece") return [seed]; + return network.pieces + .filter((p) => (scope === "line" ? p.line === piece.line : p.year === piece.year)) + .map((p) => p.id); + } - /** 고른 선에 꼴 바꿈 하나 — 까닭 글이면 알림. */ + function choose(next: PlanSelection | null): void { + selected = next && next.pieces.length ? next : null; + params.onSelect(); + } + + function select(pieces: string[], mark: string | null = null): void { + choose({ pieces: [...new Set(pieces)], scope: "piece", seed: pieces[0] ?? null, mark }); + } + + function setScope(scope: PlanScope): void { + const seed = selected?.seed ?? selected?.pieces[0]; + if (!seed) return; + choose({ pieces: expand(seed, scope), scope, seed, mark: null }); + } + + function lineIndex(): number { + const network = params.network(); + if (!selected) return -1; + const lines = new Set( + selected.pieces.map((id) => network.pieces.find((p) => p.id === id)?.line), + ); + if (lines.size !== 1) return -1; + const [id] = lines; + return network.lines.findIndex((line) => line.id === id); + } + + /** 고른 구간의 선에 꼴 바꿈 하나 — 까닭 글이면 알림. */ function apply(step: (network: PlanNetwork, index: number) => PlanNetwork | string): void { const network = params.network(); - const index = indexOf(network); - if (index < 0 || !params.editable()) return; + const index = lineIndex(); + if (!params.editable()) return; + if (index < 0) return void showToast("선 하나의 구간만 골랐을 때 됩니다.", "info"); const next = step(network, index); if (typeof next === "string") return void showToast(next, "info"); params.commit(next); sync(); } - function panelState(): void { + function dropMark(): void { const network = params.network(); - const index = indexOf(network); - panel.hidden = index < 0; - if (index < 0) return; - const line = network.lines[index]; - info.textContent = `고른 선 — ${lineNames(network.lines)[index]} · ${lineLength(line.vertices).toFixed(1)} m`; - flipButton.hidden = dropButton.hidden = !params.editable(); - flipButton.disabled = !!line.branch; + const piece = network.pieces.find((p) => p.id === selected?.mark); + if (!piece || !params.editable()) return; + params.commit(removeMark(network, piece.line, piece.from_m, 1e-3)); + sync(); } - function select(line: string | null, piece: string | null = null): void { - selected = line ? { line, piece } : null; - panelState(); - params.onSelect(); + /** Delete — 마크 · 선 통째 고름 = 그 선. */ + function dropSelected(): void { + if (selected?.mark) return dropMark(); + const network = params.network(); + const index = lineIndex(); + const whole = + index >= 0 && + network.pieces + .filter((p) => p.line === network.lines[index].id) + .every((p) => selected!.pieces.includes(p.id)); + if (!whole) + return void showToast("선 넓이로 고르면 지웁니다(다시 누름 · 속성 판 [선]).", "info"); + apply((net, i) => removeLine(net, i)); } function sync(): void { - if (selected && indexOf(params.network()) < 0) selected = null; - if (selected?.piece && !params.network().pieces.some((p) => p.id === selected!.piece)) - selected = { ...selected, piece: null }; - panelState(); + if (!selected) return params.onSelect(); + const ids = pieceIds(params.network()); + const pieces = selected.pieces.filter((id) => ids.has(id)); + selected = pieces.length + ? { + ...selected, + pieces, + seed: selected.seed && ids.has(selected.seed) ? selected.seed : pieces[0], + mark: selected.mark && ids.has(selected.mark) ? selected.mark : null, + } + : null; + params.onSelect(); } const pieceAt = (network: PlanNetwork, line: string, atM: number): string | null => @@ -178,13 +242,38 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { : null; } + /** 구간 누름 — Shift = 더함 · 뺌 · 이미 고른 곳 = 넓힘(놓을 때) · 아니면 그 구간. */ + function pressPiece( + piece: string, + shift: boolean, + x: number, + y: number, + hit: { line: number; segment: number; point: Vertex } | null, + ): void { + if (shift && selected) { + const has = selected.pieces.includes(piece); + const pieces = has + ? selected.pieces.filter((id) => id !== piece) + : [...selected.pieces, piece]; + return choose({ pieces, scope: "piece", seed: has ? pieces[0] : piece, mark: null }); + } + if (selected?.pieces.includes(piece) && !selected.mark) { + drag = { kind: "grow", piece, x, y, hit }; + return; + } + select([piece]); + } + + /** 고른 선 꼭짓점 손잡이 그릴 선(고른 구간이 든 선). */ + const pickedLines = (network: PlanNetwork): Set => + new Set( + (selected?.pieces ?? []).map((id) => network.pieces.find((p) => p.id === id)?.line ?? ""), + ); + const layer: PlanMapLayer = { draw(context, map) { const network = params.network(); - const index = indexOf(network); - if (index < 0) return; const colors = planColors(); - const line = network.lines[index]; const path = (points: Vertex[]): void => { context.beginPath(); points.forEach((point, i) => { @@ -197,26 +286,68 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { context.lineCap = "round"; context.lineJoin = "round"; context.strokeStyle = colors.active; - context.globalAlpha = 0.45; - context.lineWidth = 9; - path(line.vertices); - const piece = network.pieces.find((p) => p.id === selected?.piece); - if (piece) { - context.globalAlpha = 0.85; - context.lineWidth = 6; - path(sliceLine(line.vertices, piece.from_m, piece.to_m)); + context.globalAlpha = 0.8; + context.lineWidth = 8; + for (const id of selected?.pieces ?? []) { + const piece = network.pieces.find((p) => p.id === id); + const vertices = network.lines.find((line) => line.id === piece?.line)?.vertices; + if (piece && vertices) path(sliceLine(vertices, piece.from_m, piece.to_m)); } context.globalAlpha = 1; - // 꺾임점 손잡이 + // 꺾임점 손잡이(고른 구간이 든 선) + const lines = pickedLines(network); context.lineWidth = 2; context.fillStyle = "#ffffff"; - for (const point of line.vertices) { - const [sx, sy] = map.toScreen(point); - context.fillRect(sx - 4, sy - 4, 8, 8); - context.strokeRect(sx - 4, sy - 4, 8, 8); + for (const line of network.lines) { + if (!lines.has(line.id)) continue; + for (const point of line.vertices) { + const [sx, sy] = map.toScreen(point); + context.fillRect(sx - 4, sy - 4, 8, 8); + context.strokeRect(sx - 4, sy - 4, 8, 8); + } + } + // 고른 마크 테 + const mark = network.pieces.find((p) => p.id === selected?.mark); + const markLine = network.lines.find((line) => line.id === mark?.line)?.vertices; + if (mark && markLine) { + const [sx, sy] = map.toScreen(pointAtDistance(markLine, mark.from_m)); + context.beginPath(); + context.arc(sx, sy, 12, 0, Math.PI * 2); + context.lineWidth = 3; + context.strokeStyle = colors.active; + context.stroke(); + } + // 끌어 고르기 상자 — 보이는 화면 네모를 그림 px 로 되돌려 그림 + if (drag?.kind === "box") { + const { box } = drag; + const crossing = box.x1 < box.x0; + const color = themeColor(crossing ? "--draw-success" : "--draw-primary", "#26114a"); + context.beginPath(); + ( + [ + [box.x0, box.y0], + [box.x1, box.y0], + [box.x1, box.y1], + [box.x0, box.y1], + ] as const + ).forEach(([x, y], i) => { + const [sx, sy] = map.unrotate(x, y); + if (i) context.lineTo(sx, sy); + else context.moveTo(sx, sy); + }); + context.closePath(); + context.globalAlpha = 0.12; + context.fillStyle = color; + context.fill(); + context.globalAlpha = 1; + context.setLineDash(crossing ? [6, 4] : []); + context.lineWidth = 1.5; + context.strokeStyle = color; + context.stroke(); + context.setLineDash([]); } }, - press(px, py, map, button) { + press(px, py, map, button, shift) { if (!params.enabled()) return false; const network = params.network(); const editable = params.editable(); @@ -239,16 +370,17 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { } if (mark !== null) { const piece = network.pieces[mark]; - select(piece.line, piece.id); + select([piece.id], piece.id); if (editable) drag = { kind: "mark", index: mark, before: network, last: null }; return true; } if (vertex) { const id = network.lines[vertex.line].id; const atM = lineLength(network.lines[vertex.line].vertices.slice(0, vertex.vertex + 1)); - select(id, pieceAt(network, id, atM)); + const piece = pieceAt(network, id, atM); + if (piece && !selected?.pieces.includes(piece)) pressPiece(piece, shift, px, py, null); const lock = lockOf(network, vertex.line, vertex.vertex); - if (editable) + if (editable && !shift) drag = lock ? { kind: "locked", note: lock, told: false } : { kind: "vertex", ...vertex, before: network, last: null }; @@ -256,21 +388,28 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { } const hit = lineAt(network, px, py, map); if (hit) { - const id = network.lines[hit.line].id; - if (editable && selected?.line === id) { - params.commit(insertVertex(network, hit.line, hit.segment, hit.point)); - const before = params.network(); - drag = { kind: "vertex", line: hit.line, vertex: hit.segment + 1, before, last: null }; - } - select(id, pieceAt(params.network(), id, hit.atM)); + const piece = pieceAt(network, network.lines[hit.line].id, hit.atM); + if (piece) pressPiece(piece, shift, px, py, hit); return true; } - if (!selected) return false; - select(null); + const [vx, vy] = map.rerotate(px, py); + drag = { kind: "box", box: { x0: vx, y0: vy, x1: vx, y1: vy }, shift }; return true; }, hover(px, py, map) { if (!drag) return; + if (drag.kind === "box") { + [drag.box.x1, drag.box.y1] = map.rerotate(px, py); + return; + } + if (drag.kind === "grow") { + if (Math.hypot(px - drag.x, py - drag.y) <= DRAG_PX) return; + const hit = drag.hit; + if (!params.editable() || !hit) return void (drag = null); + params.commit(insertVertex(params.network(), hit.line, hit.segment, hit.point)); + const before = params.network(); + drag = { kind: "vertex", line: hit.line, vertex: hit.segment + 1, before, last: null }; + } if (drag.kind === "locked") { if (!drag.told) showToast(drag.note, "info"); drag.told = true; @@ -286,35 +425,58 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { } params.show(drag.last); }, - release() { + release(map) { const done = drag; drag = null; - if (!done || done.kind === "locked" || !done.last) return; + if (!done || done.kind === "locked") return; + if (done.kind === "grow") { + if (selected) { + const scope = NEXT_SCOPE[selected.scope]; + choose({ pieces: expand(done.piece, scope), scope, seed: done.piece, mark: null }); + } + return; + } + if (done.kind === "box") { + const { box, shift } = done; + if (Math.hypot(box.x1 - box.x0, box.y1 - box.y0) <= DRAG_PX) { + if (!shift) choose(null); + return; + } + const network = params.network(); + const toView = (point: Vertex): Vertex => map.rerotate(...map.toScreen(point)); + const ids = piecesInBox(network, toView, box, box.x1 < box.x0); + select(shift && selected ? [...selected.pieces, ...ids] : ids); + return; + } + if (!done.last) return; params.show(done.before); params.commit(done.last); sync(); }, }; - panelState(); - return { layer, - panel, selected: () => selected, select, + setScope, + flipLine: () => apply((network, index) => reverseLine(network, index)), + dropLine: () => apply((network, index) => removeLine(network, index)), + dropMark, + lineIndex, key(event) { if (!selected || !params.enabled()) return false; if (event.key === "Delete" && params.editable()) { - apply((network, index) => removeLine(network, index)); + dropSelected(); return true; } if (event.key === "Escape") { - select(null); + choose(null); return true; } return false; }, sync, + box: () => (drag?.kind === "box" ? { ...drag.box, crossing: drag.box.x1 < drag.box.x0 } : null), }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index ac8828bb4..25c89ff39 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -64,16 +64,20 @@ export interface ScreenMap { uprightRad: number; /** (x, y) 둘레로 글자를 눈높이로 세워 그림(돌린 지도). */ upright: (context: CanvasRenderingContext2D, x: number, y: number, paint: () => void) => void; + /** 그림 px(돌리기 전) ↔ 보이는 화면 px(585-2 끌어 고르기 상자). */ + rerotate: (px: number, py: number) => [number, number]; + unrotate: (px: number, py: number) => [number, number]; } -/** 평면 위 겹 — 그리기 · 누름(참 = 받음 · 0 왼쪽 · 2 오른쪽) · 마우스 곁(왼쪽 끌기 중도) · 놓음 · 나감. */ +/** 평면 위 겹 — 그리기 · 누름(참 = 받음 · 0 왼쪽 · 2 오른쪽 · shift = Shift 누름) · 마우스 곁(왼쪽 끌기 중도) · + * 놓음 · 나감. */ export interface PlanMapLayer { /** 바탕 겹(아래 캔버스 · 마우스 움직임엔 안 다시 그림). */ base?: boolean; draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void; - press?: (px: number, py: number, map: ScreenMap, button: 0 | 2) => boolean; + press?: (px: number, py: number, map: ScreenMap, button: 0 | 2, shift: boolean) => boolean; hover?: (px: number, py: number, map: ScreenMap) => void; - release?: () => void; + release?: (map: ScreenMap) => void; leave?: () => void; } @@ -166,10 +170,13 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { "Enter = 긋기 끝", "Esc = 긋기 취소", "Backspace = 마지막 점 빼기", - "선 누름 = 고름 · 고른 선 위 누름 = 꺾임점 더함", + "선 누름 = 구간 고름 · 다시 누름 = 선 → 같은 해로 넓힘", + "Shift+누름 = 구간 더함 · 뺌", + "빈 곳 오른쪽으로 끌기 = 창 안 구간 · 왼쪽으로 = 걸친 구간까지", + "고른 선 위 끌기 = 꺾임점 더해 옮김", "꺾임점 · 마크 끌기 = 옮김", "오른쪽 누름 = 꺾임점 · 마크 지움", - "Delete = 고른 선 지움", + "Delete = 고른 선 · 마크 지움", "가운데 끌기 = 옮기기", "휠 = 확대 · 축소", "Ctrl+휠 = 돌리기", @@ -223,6 +230,8 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { paint(); ctx.restore(); }, + rerotate: rotation.rerotate, + unrotate: rotation.unrotate, }; }; @@ -309,14 +318,15 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { if (!meta || event.button !== 0) return; const [px, py] = at(event); const map = screenMap(); - if (layers.some((layer) => layer.press?.(px, py, map, 0))) { + if (layers.some((layer) => layer.press?.(px, py, map, 0, event.shiftKey))) { canvas.setPointerCapture(event.pointerId); // 끌기가 캔버스 밖으로 나가도 놓음을 받음 drawTop(); } }); canvas.addEventListener("pointerup", (event) => { - if (event.button !== 0) return; - for (const layer of layers) layer.release?.(); + if (event.button !== 0 || !meta) return; + const map = screenMap(); + for (const layer of layers) layer.release?.(map); drawTop(); }); canvas.addEventListener("pointermove", (event) => { @@ -335,7 +345,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { if (!meta) return; const [px, py] = at(event); const map = screenMap(); - if (layers.some((layer) => layer.press?.(px, py, map, 2))) drawTop(); + if (layers.some((layer) => layer.press?.(px, py, map, 2, event.shiftKey))) drawTop(); }); bindRouteEditNavigation({ canvas, diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 00e4952f6..a8f46a252 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -3,9 +3,10 @@ * D 예정노선 2단계 노선분석(요구서 389 3장 6~13 · 440 · PLAN 562-3 · 563-1 · 579-3 · 580) — B05~B09 틀(`mountGroupStep`). * * 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 연도 구분) + 도구 여섯. - * 좌 = 지형(들어올 때 받은 범위가 시점 · 종점을 못 덮으면 바로 받기 · [범위 밖 더 받기]는 시점 · 종점 · 선이 - * 받은 범위 밖일 때만) · 그리기([긋기] · [연도 구분] · 제약 칸 · 긋는 해 · 고른 선) · - * 구역 · 연도(선 통째 해 · 조각마다 해 · 고른 줄 밝힘) · [저장] [초기화]. + * 좌 = 지형(들어올 때 받은 범위가 지점을 못 덮으면 바로 받기 · [범위 밖 더 받기]는 지점 · 선이 + * 받은 범위 밖일 때만) · 그리기([긋기] · [연도 구분] · 제약 칸 · 긋는 해) · + * 구역 · 연도(해 · 선 · 구간 줄 = 고르기 · 고른 줄 밝힘) · [저장] [초기화]. + * 고른 구간 속성(연도 · 넓이 · 방향 · 지움) = 지도 위 속성 판 `D03_PlanAnalysis_UI_Props`(585-2). * 지점 = D02 지점 목록(핀 · 늘 맨 위 · 585-1) · 고르기 · 고치기 = `D03_PlanAnalysis_UI_Edit`. * * 데이터 3층(CLAUDE.md 5장) — 긋기 · 마크 · 연도 = 캐시(`d03-plan` {version: 2, network}) · [저장] = @@ -33,7 +34,6 @@ import { currentProjectId } from "../A00_Common/b_project_switch"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler"; -import { stationLabel } from "../common_util/common_util_station"; import { fetchPlanNetwork, fetchPlanProgress, @@ -53,6 +53,7 @@ import { createPlanEdit } from "./D03_PlanAnalysis_UI_Edit"; import { createPlanLayers } from "./D03_PlanAnalysis_UI_Layers"; import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure"; +import { createPlanProps } from "./D03_PlanAnalysis_UI_Props"; import { lineLength, stationChainages } from "./logic/D03_PlanAnalysis_Draw"; import { emptyNetwork, @@ -60,8 +61,6 @@ import { lineNames, marksOf, normalizePieces, - setLineYear, - setPieceYear, type PlanNetwork, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; @@ -69,8 +68,6 @@ import { const POLL_MS = 1000; /** 되돌리기 쌓는 수. */ const UNDO_MAX = 100; -/** 조각 연도 고를 수 — 사업연도 시작부터. */ -const YEAR_SPAN = 10; /** 캐시 꼴 판 — 옛 꼴(지점 · 갈래 고름)은 안 읽음. */ const DRAFT_VERSION = 2; @@ -82,7 +79,6 @@ interface Draft { const sleep = (ms: number): Promise => new Promise((done) => setTimeout(done, ms)); const errorText = (error: unknown): string => error instanceof Error ? error.message : String(error); -const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1); export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const projectId = currentProjectId(); @@ -113,10 +109,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { if (draw.mode() !== "idle") setMeasure(false); refresh(); }, - onMark: (piece) => { - edit.select(network.pieces.find((p) => p.id === piece)?.line ?? null, piece); - openYear(piece); - }, + onMark: (piece) => edit.select([piece]), }); const edit = createPlanEdit({ network: () => network, @@ -130,6 +123,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { editable: () => editable, onSelect: () => refresh(), }); + const props = createPlanProps({ + network: () => network, + edit, + editable: () => editable, + yearStart: () => yearStart, + commit, + }); const measure = createPlanMeasure(() => z); const pins = pointLayer(() => points); const map = createPlanMap({ @@ -143,7 +143,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { map, points: () => [...points, ...allVertices()], }); - map.root.append(backdrop.bar, backdrop.legend); + map.root.append(backdrop.bar, backdrop.legend, props.panel); const main = document.createElement("div"); main.className = "d03-plan"; main.append(map.root); @@ -175,11 +175,9 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { }); drawYearPick.select.dataset.role = "draw-year"; const drawCard = createPanelCard("그리기", false); - drawCard.append(draw.panel, drawYearPick.root, edit.panel); - const pieceList = document.createElement("div"); - pieceList.className = "d03-plan__pieces"; + drawCard.append(draw.panel, drawYearPick.root); const pieceCard = createPanelCard("구역 · 연도", false); - pieceCard.append(pieceList); + pieceCard.append(props.list); const saveButton = createButton({ label: "저장", variant: "filled", onClick: () => void save() }); const resetButton = createButton({ label: "초기화", @@ -269,7 +267,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { measure: measuring, }); map.redraw(); - renderPieces(); + props.render(); extentButton.hidden = !editable || busy || !!surface; moreButton.hidden = !editable || busy || !surface || !outside(); if (!busy) @@ -288,81 +286,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { saveButton.disabled = busy || !dirty(); } - /** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */ - function yearOptions(): Array<{ value: string; text: string }> { - const years = new Set(Array.from({ length: YEAR_SPAN }, (_, i) => yearStart + i)); - network.pieces.forEach((piece) => years.add(piece.year)); - return [...years].sort().map((year) => ({ value: String(year), text: `${year}년` })); - } - - /** 연도 구분 마크를 놓은 뒤 — 뒤 조각 해 고름을 엶. */ - function openYear(piece: string): void { - // 지도 누름의 기본 동작(초점 걷음)이 끝난 뒤 - setTimeout(() => { - const pick = pieceList.querySelector(`[data-piece="${piece}"] select`); - if (!pick) return; - pick.scrollIntoView({ block: "nearest" }); - pick.focus(); - try { - pick.showPicker(); - } catch { - // 사용자 누름 밖이면 못 엶 — 초점만 - } - }); - } - - function renderPieces(): void { - const options = yearOptions(); - const names = lineNames(network.lines); - const picked = edit.selected(); - const rows: HTMLElement[] = []; - network.lines.forEach((line, index) => { - const pieces = network.pieces.filter((piece) => piece.line === line.id); - if (!pieces.length) return; - const years = new Set(pieces.map((piece) => piece.year)); - const whole = createSelectField({ - options: years.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options, - value: years.size > 1 ? "" : String(pieces[0].year), - size: 24, - ariaLabel: "선 통째 연도", - disabled: !editable, - onChange: (value) => value && commit(setLineYear(network, line.id, Number(value))), - }); - const head = document.createElement("div"); - head.className = "d03-plan__piece d03-plan__piece-head"; - head.classList.toggle("is-selected", picked?.line === line.id && !picked.piece); - head.dataset.line = line.id; - head.append( - createText(`${names[index]} · ${lineLength(line.vertices).toFixed(1)} m`, "muted"), - whole.root, - ); - rows.push(head); - for (const piece of pieces) { - const row = document.createElement("div"); - row.className = "d03-plan__piece"; - row.classList.toggle("is-selected", picked?.piece === piece.id); - row.dataset.piece = piece.id; - const span = createText( - `${station(piece.from_m)} ~ ${station(piece.to_m)} (${(piece.to_m - piece.from_m).toFixed(1)} m)`, - "muted", - ); - const pick = createSelectField({ - options, - value: String(piece.year), - size: 24, - ariaLabel: "연도", - disabled: !editable, - onChange: (value) => - commit(setPieceYear(network, network.pieces.indexOf(piece), Number(value))), - }); - row.append(span, pick.root); - rows.push(row); - } - }); - if (!rows.length) rows.push(createText("선을 그으면 구역이 생깁니다.", "muted")); - pieceList.replaceChildren(...rows); - } - /** 받기 전 범위를 카카오 지도에 보임 → [이 범위 받기]. */ async function openExtent(extra: Vertex[]): Promise { let extent: TerrainExtent; @@ -581,6 +504,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { stroke: draw.stroke, selected: edit.selected, select: edit.select, + setScope: edit.setScope, + box: edit.box, drawYear: () => drawYear, constraint: draw.constraint, setConstraint: draw.setConstraint, @@ -629,7 +554,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { epsg = base.epsg; takeServer(view); drawYear = yearStart; - drawYearPick.setOptions(yearOptions(), String(drawYear)); + 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()] }); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts new file mode 100644 index 000000000..b5d51c9a4 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts @@ -0,0 +1,239 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Props.ts + * D03 고른 구간 속성 판 · 좌측 「구역 · 연도」 목록(PLAN 585-2 · 요구서 445 A4) — 오토캐드 특성 창 꼴. + * + * 속성 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 — + * 머리 「구간 N · 선 M · L m」 · ✕ = 풂 · 넓이 [구간][선][같은 해] · 연도(모두 같으면 그 해 · 다르면 「여러 해」 → + * 고르면 고른 구간 모두 그 해 = 되돌리기 한 걸음) · 고른 구간 줄(누름 = 그 구간만) · + * [방향 바꾸기] [선 지움](선 하나일 때) · [마크 지움](마크 고름) · 보기만 계정 = 값만. + * 좌측 목록 = 보기 + 고르기만(해 고침은 속성 판 한 곳) — 해 줄(같은 해) · 선 머리 줄(선) · 구간 줄(구간). + * ========================================================================== */ + +import { createButton } from "@ui/ui_template_elements"; +import { createFloatPanel } from "@ui/ui_template_float_panel"; +import { createSelectField } from "@ui/ui_template_input"; +import { createSegment } from "@ui/ui_template_segment"; +import { createText } from "@ui/ui_template_text"; +import { stationLabel } from "../common_util/common_util_station"; +import { lineLength } from "./logic/D03_PlanAnalysis_Draw"; +import { lineNames, setPiecesYear, type PlanNetwork } from "./logic/D03_PlanAnalysis_Plan"; +import type { PlanEdit, PlanScope } from "./D03_PlanAnalysis_UI_Edit"; +import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; + +/** 조각 연도 고를 수 — 사업연도 시작부터. */ +const YEAR_SPAN = 10; + +const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1); +const meters = (m: number): string => `${m.toFixed(1)} m`; + +export interface PlanPropsParams { + network: () => PlanNetwork; + edit: PlanEdit; + editable: () => boolean; + yearStart: () => number; + commit: (next: PlanNetwork) => void; +} + +export interface PlanProps { + /** 지도 위 속성 판(지도 칸에 붙임). */ + panel: HTMLElement; + /** 좌측 「구역 · 연도」 목록 몸. */ + list: HTMLElement; + /** 고름 · 꼴이 바뀜 — 판 · 목록 다시. */ + render: () => void; + /** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */ + yearOptions: () => Array<{ value: string; text: string }>; +} + +export function createPlanProps(params: PlanPropsParams): PlanProps { + const { edit } = params; + const float = createFloatPanel({ + title: "고른 구간", + draggable: true, + onClose: () => edit.select([]), + }); + float.root.classList.add("d03-plan__props"); + float.root.dataset.role = "plan-props"; + float.root.hidden = true; + const list = document.createElement("div"); + list.className = "d03-plan__pieces"; + + function yearOptions(): Array<{ value: string; text: string }> { + const start = params.yearStart(); + const years = new Set(Array.from({ length: YEAR_SPAN }, (_, i) => start + i)); + params.network().pieces.forEach((piece) => years.add(piece.year)); + return [...years].sort().map((year) => ({ value: String(year), text: `${year}년` })); + } + + function renderPanel(): void { + const picked = edit.selected(); + float.root.hidden = !picked; + if (!picked) return float.body.replaceChildren(); + const network = params.network(); + const names = lineNames(network.lines); + const pieces = network.pieces.filter((piece) => picked.pieces.includes(piece.id)); + const lineIds = new Set(pieces.map((piece) => piece.line)); + const total = pieces.reduce((sum, piece) => sum + piece.to_m - piece.from_m, 0); + const title = float.root.querySelector(".ui-float-panel__title"); + if (title) title.textContent = `구간 ${pieces.length} · 선 ${lineIds.size} · ${meters(total)}`; + const editable = params.editable(); + + const scope = createSegment({ + options: [ + { value: "piece", label: "구간" }, + { value: "line", label: "선" }, + { value: "year", label: "같은 해" }, + ], + value: picked.scope, + size: 24, + ariaLabel: "고름 넓이", + onChange: (value) => edit.setScope(value as PlanScope), + }); + scope.root.dataset.role = "props-scope"; + + const years = new Set(pieces.map((piece) => piece.year)); + const options = yearOptions(); + const year = createSelectField({ + label: "연도", + options: years.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options, + value: years.size > 1 ? "" : String(pieces[0]?.year ?? ""), + size: 24, + ariaLabel: "고른 구간 연도", + disabled: !editable, + onChange: (value) => + value && params.commit(setPiecesYear(params.network(), picked.pieces, Number(value))), + }); + year.select.dataset.role = "props-year"; + + const rows = document.createElement("div"); + rows.className = "d03-plan__props-rows"; + for (const piece of pieces) { + const index = network.lines.findIndex((line) => line.id === piece.line); + const row = createButton({ + label: + `${names[index]} ${station(piece.from_m)}~${station(piece.to_m)} · ` + + `${meters(piece.to_m - piece.from_m)} · ${piece.year}년`, + variant: "ghost", + size: 24, + onClick: () => edit.select([piece.id]), + }); + row.dataset.piece = piece.id; + rows.append(row); + } + + const actions = document.createElement("div"); + actions.className = "d03-plan__row"; + const index = edit.lineIndex(); + if (editable && index >= 0) { + const flip = createButton({ + label: "방향 바꾸기", + variant: "ghost", + size: 24, + onClick: edit.flipLine, + }); + flip.title = "측점 No.0 쪽(시점 쪽)을 반대 끝으로 바꿉니다."; + flip.disabled = !!network.lines[index].branch; + flip.dataset.role = "props-flip"; + const drop = createButton({ + label: "선 지움", + variant: "ghost", + size: 24, + onClick: edit.dropLine, + }); + drop.dataset.role = "props-drop"; + actions.append(flip, drop); + } + if (editable && picked.mark) { + const dropMark = createButton({ + label: "마크 지움", + variant: "ghost", + size: 24, + onClick: edit.dropMark, + }); + dropMark.dataset.role = "props-drop-mark"; + actions.append(dropMark); + } + float.body.replaceChildren(scope.root, year.root, rows, actions); + } + + /** 좌측 줄 — 누름 = 고름(div · 열쇠 Enter 도). */ + const pickRow = (row: HTMLElement, onPick: () => void): HTMLElement => { + row.tabIndex = 0; + row.setAttribute("role", "button"); + row.addEventListener("click", onPick); + row.addEventListener("keydown", (event) => event.key === "Enter" && onPick()); + return row; + }; + + function renderList(): void { + const network = params.network(); + const names = lineNames(network.lines); + const picked = new Set(edit.selected()?.pieces ?? []); + const scope = edit.selected()?.scope; + const rows: HTMLElement[] = []; + const byYear = new Map(); + for (const piece of network.pieces) + byYear.set(piece.year, [...(byYear.get(piece.year) ?? []), piece]); + for (const [year, pieces] of [...byYear].sort(([a], [b]) => a - b)) { + const row = document.createElement("div"); + row.className = "d03-plan__piece d03-plan__piece-year"; + row.dataset.year = String(year); + row.classList.toggle( + "is-selected", + scope === "year" && pieces.every((p) => picked.has(p.id)), + ); + const length = pieces.reduce((sum, p) => sum + p.to_m - p.from_m, 0); + row.append(createText(`${year}년 · 구간 ${pieces.length} · ${meters(length)}`, "muted")); + rows.push( + pickRow(row, () => { + edit.select([pieces[0].id]); + edit.setScope("year"); + }), + ); + } + network.lines.forEach((line, index) => { + const pieces = network.pieces.filter((piece) => piece.line === line.id); + if (!pieces.length) return; + const head = document.createElement("div"); + head.className = "d03-plan__piece d03-plan__piece-head"; + head.dataset.line = line.id; + head.classList.toggle( + "is-selected", + scope === "line" && pieces.every((p) => picked.has(p.id)), + ); + head.append(createText(`${names[index]} · ${meters(lineLength(line.vertices))}`, "muted")); + rows.push( + pickRow(head, () => { + edit.select([pieces[0].id]); + edit.setScope("line"); + }), + ); + for (const piece of pieces) { + const row = document.createElement("div"); + row.className = "d03-plan__piece"; + row.classList.toggle("is-selected", picked.has(piece.id)); + row.dataset.piece = piece.id; + row.append( + createText( + `${station(piece.from_m)} ~ ${station(piece.to_m)} (${meters(piece.to_m - piece.from_m)})`, + "muted", + ), + createText(`${piece.year}년`, "muted"), + ); + rows.push(pickRow(row, () => edit.select([piece.id]))); + } + }); + if (!rows.length) rows.push(createText("선을 그으면 구역이 생깁니다.", "muted")); + list.replaceChildren(...rows); + } + + return { + panel: float.root, + list, + render() { + renderPanel(); + renderList(); + }, + yearOptions, + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index 84d4434f9..91d115a5b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -150,3 +150,41 @@ background: var(--color-accent-soft); border-radius: var(--radius-md); } + +/* 585-2 고른 구간 속성 판 — 도구줄 아래 오른쪽 · 몸 스크롤 · 구간 줄 = 고스트 단추 */ +.d03-plan__props { + top: calc(var(--spacing-8) * 6); + right: var(--spacing-8); + width: 300px; + max-height: calc(100% - var(--spacing-8) * 8); +} + +.d03-plan__props[hidden] { + display: none; +} + +.d03-plan__props .ui-float-panel__body { + display: flex; + flex-direction: column; + gap: 6px; + padding: var(--spacing-8); + overflow: auto; +} + +.d03-plan__props-rows { + display: flex; + flex-direction: column; + align-items: stretch; +} + +.d03-plan__props-rows > .ui-btn { + justify-content: flex-start; +} + +.d03-plan__piece[role="button"] { + cursor: pointer; +} + +.d03-plan__piece-year { + font-weight: 600; +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts index 36f4ae731..bae459d83 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts @@ -1,12 +1,12 @@ /* ============================================================================= * D03_PlanAnalysis_Plan.ts * D 예정노선 2단계 손 긋기 꼴(PLAN 562-3 · 580) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기 · - * 선 뒤집기 · 잇기 · 꺾임점 옮김 · 지움 · 선 지움 · 선 통째 연도 · 마크 옮김. + * 선 뒤집기 · 잇기 · 꺾임점 옮김 · 지움 · 선 지움 · 선 통째 연도 · 마크 옮김 · 여러 조각 연도 · 끌어 고르기 판정(585-2). * DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`). * [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음. * ========================================================================== */ -import { lineLength, segmentLength } from "./D03_PlanAnalysis_Draw"; +import { lineLength, segmentLength, sliceLine } from "./D03_PlanAnalysis_Draw"; export type Vertex = [number, number]; @@ -504,3 +504,72 @@ export function moveMark(network: PlanNetwork, index: number, atM: number): Plan ), }; } + +/** 여러 조각 연도 바꿈(585-2 속성 판) — `ids` 조각 모두 `year`. */ +export function setPiecesYear( + network: PlanNetwork, + ids: ReadonlyArray, + year: number, +): PlanNetwork { + const pick = new Set(ids); + return { + ...network, + pieces: network.pieces.map((piece) => (pick.has(piece.id) ? { ...piece, year } : piece)), + }; +} + +/** 끌어 고르기 상자(보이는 화면 px · 축에 바른 네모). */ +export interface ViewBox { + x0: number; + y0: number; + x1: number; + y1: number; +} + +/** 토막 a–b 가 네모와 만나는가(한 끝이 안 · 또는 네 변 중 하나와 교차). */ +function segmentHitsBox(a: Vertex, b: Vertex, box: ViewBox): boolean { + const inside = ([x, y]: Vertex): boolean => + x >= box.x0 && x <= box.x1 && y >= box.y0 && y <= box.y1; + if (inside(a) || inside(b)) return true; + const cross = (p: Vertex, q: Vertex, r: Vertex, s: Vertex): boolean => { + const d = (q[0] - p[0]) * (s[1] - r[1]) - (q[1] - p[1]) * (s[0] - r[0]); + if (d === 0) return false; + const t = ((r[0] - p[0]) * (s[1] - r[1]) - (r[1] - p[1]) * (s[0] - r[0])) / d; + const u = ((r[0] - p[0]) * (q[1] - p[1]) - (r[1] - p[1]) * (q[0] - p[0])) / d; + return t >= 0 && t <= 1 && u >= 0 && u <= 1; + }; + const corners: Vertex[] = [ + [box.x0, box.y0], + [box.x1, box.y0], + [box.x1, box.y1], + [box.x0, box.y1], + ]; + return corners.some((c, i) => cross(a, b, c, corners[(i + 1) % 4])); +} + +/** 끌어 고르기(585-2 · 오토캐드 꼴) — 창(`crossing` 아님) = 조각 꺾은선 점이 모두 상자 안 · + * 걸침 = 점 하나라도 안 또는 토막 하나라도 상자 변과 만남. `toView` = 작업 좌표계 점 → 보이는 px. */ +export function piecesInBox( + network: PlanNetwork, + toView: (point: Vertex) => Vertex, + box: ViewBox, + crossing: boolean, +): string[] { + const norm: ViewBox = { + x0: Math.min(box.x0, box.x1), + y0: Math.min(box.y0, box.y1), + x1: Math.max(box.x0, box.x1), + y1: Math.max(box.y0, box.y1), + }; + const inside = ([x, y]: Vertex): boolean => + x >= norm.x0 && x <= norm.x1 && y >= norm.y0 && y <= norm.y1; + return network.pieces + .filter((piece) => { + const vertices = network.lines.find((line) => line.id === piece.line)?.vertices; + if (!vertices || vertices.length < 2) return false; + const path = sliceLine(vertices, piece.from_m, piece.to_m).map(toView); + if (!crossing) return path.every(inside); + return path.some((p, i) => (i ? segmentHitsBox(path[i - 1], p, norm) : inside(p))); + }) + .map((piece) => piece.id); +} diff --git a/resources/tester/test_585_2_plan_select.py b/resources/tester/test_585_2_plan_select.py new file mode 100644 index 000000000..5f0d28883 --- /dev/null +++ b/resources/tester/test_585_2_plan_select.py @@ -0,0 +1,90 @@ +"""585-2 예정노선 끌어 고르기 판정 · 여러 구간 연도(요구 445 A4 · A5) — TS `logic/D03_PlanAnalysis_Plan.ts`. + +창(오른쪽으로) = 구간 꺾은선 점이 모두 상자 안 · 걸침(왼쪽으로) = 점 하나라도 안 또는 토막이 상자 변과 만남 · +돌린 화면도 보이는 px 로 셈 · 여러 구간 연도 한 번에. + +./venv/Scripts/python.exe -m pytest resources/tester/test_585_2_plan_select.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Plan.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"; +const Y = 2026; +// 본선 (0,0)→(100,0)→(100,100) · 마크 50 · 150 → p1 [0,50] p2 [50,150] p3 [150,200] · 둘째 선 (200,0)→(300,0) +let n = plan.newLine(plan.emptyNetwork(), [0, 0]); +n = plan.appendVertex(plan.appendVertex(n, 0, [100, 0]), 0, [100, 100]); +n = plan.normalizePieces(n, Y); +n = plan.normalizePieces(plan.splitPiece(n, "main", 50), Y); +n = plan.normalizePieces(plan.splitPiece(n, "main", 150), Y); +n = plan.normalizePieces(plan.appendVertex(plan.newLine(n, [200, 0]), 1, [300, 0]), Y); +const view = ([x, y]) => [x, -y]; // 화면 y 아래로 +const turned = ([x, y]) => [-y, -x]; // 90° 돌린 화면 +const box = (x0, y0, x1, y1) => ({ x0, y0, x1, y1 }); +const pick = (b, crossing, to = view) => plan.piecesInBox(n, to, b, crossing); +writeFileSync(process.argv[2], JSON.stringify({ + pieces: n.pieces.map((p) => [p.id, p.line, p.from_m, p.to_m]), + window: pick(box(-10, -10, 60, 10), false), + crossing: pick(box(60, 10, -10, -10), true), + through: [pick(box(70, -5, 80, 5), true), pick(box(70, -5, 80, 5), false)], + wholeLine: pick(box(190, -5, 310, 5), false), + partLine: [pick(box(190, -5, 250, 5), false), pick(box(250, 5, 190, -5), true)], + 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]), +})); +""" + + +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, + ) # fmt: skip + 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_창_걸침_판정(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + ids = {p[0]: p[1:] for p in got["pieces"]} + assert ids["p1"] == ["main", 0, 50] and ids["p2"] == ["main", 50, 150] + other = next(k for k, v in ids.items() if v[0] != "main") + assert got["window"] == ["p1"] # p2 는 (100, 100) 쪽이 상자 밖 + assert got["crossing"] == ["p1", "p2"] # 왼쪽으로 = p2 앞 끝이 상자 안 + assert got["through"] == [["p2"], []] # 점 없이 토막만 지남 = 걸침만 + assert got["wholeLine"] == [other] # 마커 없는 선 = 구간 하나 = 선 통째 + assert got["partLine"] == [[], [other]] # 선 반만 = 창 없음 · 걸침 있음 + assert sorted(got["all"]) == sorted(ids) + assert got["rotated"] == ["p3"] # 돌린 화면 = 보이는 px 로 셈 + + +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