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:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user