feat(D03): 예정노선 구간 고르기 넓힘 · 속성 판 · 끌어 고르기 창/걸침 (PLAN 585-2 · 요구 445 A4 · A5)

- 고름 = 구간 묶음 + 넓이 — 선 누름 = 구간 · 다시 누름 = 선 → 같은 해 → 구간 · Shift+누름 = 더함 · 뺌
- 빈 곳 끌기 = 오른쪽으로 창(실선 · 다 든 구간) · 왼쪽으로 걸침(점선 · 걸친 구간까지) · 돌린 지도도 보이는 화면 기준
- 지도 위 속성 판(새 _UI_Props · 공용 떠 있는 판) — 넓이 · 연도(여러 구간 한 번에) · 구간 줄 · 방향 바꾸기 · 선 지움 · 마크 지움
- 좌측 구역 · 연도 = 해 줄 · 선 줄 · 구간 줄 누름 = 고르기(해 고침은 속성 판 한 곳) · 고른 선 위 끌기 = 꺾임점 더해 옮김
- Delete = 고른 마크 · 선 통째 고름이면 그 선 지움
- 시험: 585-2 새 지킴 시험(창 · 걸침 · 돌린 화면 · 여러 구간 연도)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 18:46:01 +09:00
co-authored by Claude Opus 5.5
parent f5645c256d
commit e6951b5c73
7 changed files with 734 additions and 201 deletions
+257 -95
View File
@@ -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<PlanScope, PlanScope> = { 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<string> =>
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<string> =>
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),
};
}
+19 -9
View File
@@ -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,
+20 -95
View File
@@ -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<void> => 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<void> {
const projectId = currentProjectId();
@@ -113,10 +109,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
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<void> {
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<void> {
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<void> {
});
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<void> {
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<void> {
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<HTMLSelectElement>(`[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<void> {
let extent: TerrainExtent;
@@ -581,6 +504,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
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<void> {
epsg = base.epsg;
takeServer(view);
drawYear = yearStart;
drawYearPick.setOptions(yearOptions(), String(drawYear));
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()] });
@@ -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<number, typeof network.pieces>();
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,
};
}
@@ -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;
}
@@ -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<string>,
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);
}
@@ -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