merge(D03 겹): dev 합침 — 581-1 겹 일곱 + 582 두 장 캔버스 + 579 · 580 그리기 나눔 양쪽 살림
- UI_Layers.ts: 582 바탕 캔버스(`base`) + 581-1 새 겹 일곱 · 겹 열둘 모두 `base: true` · 글 폭 기억 · drawRaster 둘 다 - UI_Style.css: 582 `.d03-plan__base` 블록 + 581-1 `--d03-layers-w` 글 - test_553_4: 580 DrawPaint 단언 + 581-1 createMapTools · LayerDefs 단언 - test_582: 바탕 겹 수 5 → 겹 모두(12) — 581-1 합침으로 낡은 수 고침 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import type { VWorldMeta } from "./B04_PreProcess_Api_Fetch";
|
||||
import { pickLodList } from "./logic/B04_PreProcess_UI_MapRender_Prepare";
|
||||
|
||||
// 2D 지도 벡터 레이어 렌더 엔진.
|
||||
// GeoJSON 좌표를 로드 시 1회만 정규화 맵 좌표(0~1)로 사전 투영해 두고,
|
||||
@@ -46,6 +47,14 @@ export type PreparedPart = {
|
||||
coords: Float64Array;
|
||||
closed: boolean;
|
||||
weights: Float64Array | null;
|
||||
/** 줌 단계 색인(582 · `indexLayerLod` 가 붙임 · 없으면 정점 전부 훑음) — 그리는 정점은 같음. */
|
||||
lod?: PartLod;
|
||||
};
|
||||
|
||||
/** 단마다 「무게 ≥ 그 단」 정점 번호(원래 차례). thresholds 는 큰 → 작은 차례. */
|
||||
export type PartLod = {
|
||||
thresholds: number[];
|
||||
lists: Uint32Array[];
|
||||
};
|
||||
|
||||
/** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */
|
||||
@@ -375,6 +384,9 @@ function drawLineParts(
|
||||
const coords = part.coords;
|
||||
if (coords.length < 4) continue;
|
||||
const weights = part.weights;
|
||||
// 줌 단계 색인(582) = 「무게 ≥ 그 단」 정점만 훑음 — 아래 거름이 그대로라 그리는 정점 같음.
|
||||
const list = part.lod ? pickLodList(part.lod, tolerance) : null;
|
||||
const count = list ? list.length : coords.length / 2;
|
||||
// 현재 줌에서 화면 오차 LOD_PX 미만인 정점만 생략 (끝점은 weight=∞라 항상 유지).
|
||||
// 정점 사이 보간은 하지 않는다 — 원본 데이터의 형상 그대로 표시 (2026-07-28 사용자 지시).
|
||||
context.beginPath();
|
||||
@@ -388,8 +400,10 @@ function drawLineParts(
|
||||
let prevX = 0;
|
||||
let prevY = 0;
|
||||
let prevSide = 0;
|
||||
for (let i = 0; i < coords.length; i += 2) {
|
||||
if (weights && weights[i / 2] < tolerance) continue;
|
||||
for (let k = 0; k < count; k += 1) {
|
||||
const v = list ? list[k] : k;
|
||||
if (weights && weights[v] < tolerance) continue;
|
||||
const i = v * 2;
|
||||
const x = coords[i] * affine.ax + affine.bx;
|
||||
const y = coords[i + 1] * affine.ay + affine.by;
|
||||
const side = sideOf(x, y);
|
||||
@@ -584,6 +598,8 @@ export function drawPreparedLabels(
|
||||
uprightRad = 0,
|
||||
/** 글자 테두리 색 — 지도 바탕색과 같아야 글자가 선을 끊어 보인다. 기본 = `--map-halo`. */
|
||||
halo = haloColor(),
|
||||
/** 글 폭 기억(582 · 글 → 폭 px) — 부르는 쪽이 글꼴마다 하나 둠 · 없으면 그때마다 잼. */
|
||||
widths?: Map<string, number>,
|
||||
): void {
|
||||
const affine = affineOf(view);
|
||||
const margin = CULL_MARGIN;
|
||||
@@ -600,7 +616,12 @@ export function drawPreparedLabels(
|
||||
const y = feature.labelAnchorY * affine.ay + affine.by;
|
||||
if (x < -margin || x > view.width + margin) continue;
|
||||
if (y < -margin || y > view.height + margin) continue;
|
||||
const half = context.measureText(feature.labelText).width / 2 + LABEL_GAP_PX;
|
||||
let width = widths?.get(feature.labelText);
|
||||
if (width === undefined) {
|
||||
width = context.measureText(feature.labelText).width;
|
||||
widths?.set(feature.labelText, width);
|
||||
}
|
||||
const half = width / 2 + LABEL_GAP_PX;
|
||||
if (
|
||||
drawn.some(
|
||||
(item) => Math.abs(item.x - x) < item.half + half && Math.abs(item.y - y) < LABEL_ROW_PX,
|
||||
|
||||
@@ -13,11 +13,44 @@ import type {
|
||||
GeoJsonCollection,
|
||||
GeoJsonGeometry,
|
||||
Normalizer,
|
||||
PartLod,
|
||||
PreparedFeature,
|
||||
PreparedLayer,
|
||||
PreparedPart,
|
||||
} from "../B04_PreProcess_UI_MapRender";
|
||||
|
||||
/** 줌 단계 무게(정규화 좌표 · 큰 → 작은 · 4 배 간격) — 화면 1 폭 ≈ 1,400 px 이면 다 줌아웃 ~ 2,000 배. */
|
||||
const LOD_THRESHOLDS = [4 ** -5, 4 ** -6, 4 ** -7, 4 ** -8, 4 ** -9, 4 ** -10];
|
||||
/** 이보다 짧은 조각은 색인 없이 전부 훑음(훑기가 이미 짧음). */
|
||||
const LOD_MIN_VERTICES = 64;
|
||||
|
||||
/** 조각 하나의 줌 단계 색인. 짧거나 무게가 없으면 undefined. */
|
||||
export function buildPartLod(weights: Float64Array | null): PartLod | undefined {
|
||||
if (!weights || weights.length < LOD_MIN_VERTICES) return undefined;
|
||||
const lists = LOD_THRESHOLDS.map((threshold) => {
|
||||
const kept: number[] = [];
|
||||
for (let v = 0; v < weights.length; v += 1) if (weights[v] >= threshold) kept.push(v);
|
||||
return Uint32Array.from(kept);
|
||||
});
|
||||
return { thresholds: LOD_THRESHOLDS, lists };
|
||||
}
|
||||
|
||||
/** 허용 오차에 맞는 가장 성긴 단 — 「무게 ≥ 오차」 정점을 모두 담음. 맞는 단이 없으면 null(전부 훑음). */
|
||||
export function pickLodList(lod: PartLod, tolerance: number): Uint32Array | null {
|
||||
for (let k = 0; k < lod.thresholds.length; k += 1)
|
||||
if (lod.thresholds[k] <= tolerance) return lod.lists[k];
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 레이어 선 조각마다 줌 단계 색인을 붙임(제자리 · 582 — 지금 D03 만 부름). */
|
||||
export function indexLayerLod(layer: PreparedLayer): PreparedLayer {
|
||||
for (const feature of layer.features) {
|
||||
if (feature.kind !== "line") continue;
|
||||
for (const part of feature.parts) part.lod = buildPartLod(part.weights);
|
||||
}
|
||||
return layer;
|
||||
}
|
||||
|
||||
function isPoint(value: unknown): value is [number, number] {
|
||||
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""D 예정노선 1단계 기준 지정 — 전체 시점 · 종점 두 점 정본 API (요구서 389 3장 1 · PLAN 560-2).
|
||||
"""D 예정노선 1단계 기준 지정 — 전체 시점 · 종점 목록 정본 API
|
||||
(요구서 389 3장 1 · 440 1 · PLAN 560-2 · 579-1).
|
||||
|
||||
- `GET /api/projects/{id}/plan/points` — {epsg, start, end} · 점 = {lon, lat, x, y} · 빈 = null
|
||||
- `PUT …/plan/points` — {start: {lon, lat}, end: {lon, lat}} → TM 셈 → `projects.plan_base_json`
|
||||
(`{"epsg", "start": {"x", "y"}, "end": {"x", "y"}}` · 좌표계 = 두 점 평균 경도의 TM 띠)
|
||||
- `GET /api/projects/{id}/plan/points` — {epsg, starts: [점], ends: [점]} · 점 = {lon, lat, x, y}
|
||||
- `PUT …/plan/points` — {starts: [{lon, lat}], ends: [...]} → TM 셈 → `projects.plan_base_json`
|
||||
(`{"epsg", "starts": [{"x", "y"}], "ends": [...]}` · 좌표계 = 저장된 값 ·
|
||||
없으면 점 평균 경도의 TM 띠)
|
||||
- 시점 · 종점은 짝 없는 따로 목록 · 옛 꼴(`start` · `end` 한 점씩)은 읽을 때 목록으로 감쌈
|
||||
(옮김 없음).
|
||||
- 칸이 비면(옛 프로젝트) 첫 연도 시점 + 마지막 연도 종점으로 접어 보임 · 연도 행은 D03 [저장] 몫.
|
||||
- 고침 = 산림청만(지정 회사 GET 은 공용 판정 `require_project_access` 몫).
|
||||
|
||||
연도 잇기 꼴(`chain_years` · `with_tm` · `changed` · `_store`)은 D03 [저장]이 씀.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,7 +27,7 @@ from config.config_db import get_db_pool
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["D02 Plan Base"])
|
||||
|
||||
SAME_M = 0.001 # 1 mm 안이면 같은 지점 — 되저장으로 분석 노선을 지우지 않음
|
||||
MAX_POINTS = 20 # 시점 · 종점 목록마다 상한
|
||||
|
||||
|
||||
class LonLat(BaseModel):
|
||||
@@ -33,60 +35,9 @@ class LonLat(BaseModel):
|
||||
lat: float = Field(..., ge=30, le=45)
|
||||
|
||||
|
||||
class YearPoints(BaseModel):
|
||||
year: int
|
||||
start: LonLat | None = None
|
||||
end: LonLat | None = None
|
||||
|
||||
|
||||
class BaseRequest(BaseModel):
|
||||
start: LonLat | None = None
|
||||
end: LonLat | None = None
|
||||
|
||||
|
||||
def chain_years(year_start: int, year_end: int, items: list[YearPoints]) -> list[dict[str, Any]]:
|
||||
"""연도 범위를 잇기 — [{year, start, end}] 경위도 · 시점 = 직전 종점(첫 연도만 보낸 값)."""
|
||||
by_year: dict[int, YearPoints] = {}
|
||||
for item in items:
|
||||
if not year_start <= item.year <= year_end:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{item.year}년은 사업연도({year_start}~{year_end}) 밖입니다.",
|
||||
)
|
||||
if item.year in by_year:
|
||||
raise HTTPException(status_code=400, detail=f"{item.year}년이 두 번 왔습니다.")
|
||||
by_year[item.year] = item
|
||||
rows: list[dict[str, Any]] = []
|
||||
previous: LonLat | None = None
|
||||
for year in range(year_start, year_end + 1):
|
||||
item = by_year.get(year)
|
||||
start = previous or (item.start if item else None)
|
||||
end = item.end if item else None
|
||||
if start is None or end is None:
|
||||
side = "시점" if start is None else "종점"
|
||||
raise HTTPException(status_code=400, detail=f"{year}년 {side}이 비었습니다.")
|
||||
rows.append({"year": year, "start": start, "end": end})
|
||||
previous = end
|
||||
return rows
|
||||
|
||||
|
||||
def with_tm(rows: list[dict[str, Any]]) -> tuple[int, list[dict[str, Any]]]:
|
||||
"""(epsg, 행마다 start_x · start_y · end_x · end_y 더함)."""
|
||||
lons = [p.lon for row in rows for p in (row["start"], row["end"])]
|
||||
epsg = tm_epsg_for_lon(sum(lons) / len(lons))
|
||||
out = []
|
||||
for row in rows:
|
||||
sx, sy = lonlat_to_tm(row["start"].lon, row["start"].lat, epsg)
|
||||
ex, ey = lonlat_to_tm(row["end"].lon, row["end"].lat, epsg)
|
||||
out.append({"year": row["year"], "start_x": sx, "start_y": sy, "end_x": ex, "end_y": ey})
|
||||
return epsg, out
|
||||
|
||||
|
||||
def changed(new: dict[str, Any], old: dict[str, Any] | None, epsg: int) -> bool:
|
||||
if not old or old.get("epsg") is None or int(old["epsg"]) != epsg:
|
||||
return True
|
||||
keys = ("start_x", "start_y", "end_x", "end_y")
|
||||
return any(old[k] is None or abs(float(old[k]) - new[k]) > SAME_M for k in keys)
|
||||
starts: list[LonLat] = Field(default_factory=list, max_length=MAX_POINTS)
|
||||
ends: list[LonLat] = Field(default_factory=list, max_length=MAX_POINTS)
|
||||
|
||||
|
||||
async def _load(project_id: UUID) -> tuple[dict[str, Any], dict[int, dict[str, Any]]]:
|
||||
@@ -114,44 +65,6 @@ async def _load(project_id: UUID) -> tuple[dict[str, Any], dict[int, dict[str, A
|
||||
return project, {int(row["year"]): row for row in rows}
|
||||
|
||||
|
||||
async def _store(
|
||||
project_id: UUID, epsg: int, writes: list[dict[str, Any]], drops: list[int]
|
||||
) -> None:
|
||||
"""바뀐 연도만 넣고(분석 노선 비움) · 범위 밖 연도 행은 지움 — 한 묶음."""
|
||||
async with get_db_pool().acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
async with connection.cursor() as cursor:
|
||||
for row in writes:
|
||||
await cursor.execute(
|
||||
"INSERT INTO plan_route_years"
|
||||
" (project_id, year, start_x, start_y, end_x, end_y, epsg)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s, %s)"
|
||||
" ON DUPLICATE KEY UPDATE start_x = VALUES(start_x),"
|
||||
" start_y = VALUES(start_y), end_x = VALUES(end_x), end_y = VALUES(end_y),"
|
||||
" epsg = VALUES(epsg), route_json = NULL, fingerprint = NULL,"
|
||||
" confirmed_at = NULL",
|
||||
(
|
||||
str(project_id),
|
||||
row["year"],
|
||||
row["start_x"],
|
||||
row["start_y"],
|
||||
row["end_x"],
|
||||
row["end_y"],
|
||||
epsg,
|
||||
),
|
||||
)
|
||||
for year in drops:
|
||||
await cursor.execute(
|
||||
"DELETE FROM plan_route_years WHERE project_id = %s AND year = %s",
|
||||
(str(project_id), year),
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def _point(x: Any, y: Any, epsg: Any) -> dict[str, float] | None:
|
||||
if x is None or y is None or epsg is None:
|
||||
return None
|
||||
@@ -159,45 +72,51 @@ def _point(x: Any, y: Any, epsg: Any) -> dict[str, float] | None:
|
||||
return {"lon": lon, "lat": lat, "x": float(x), "y": float(y)}
|
||||
|
||||
|
||||
def base_view(project: dict[str, Any], rows: dict[int, dict[str, Any]]) -> dict[str, Any]:
|
||||
"""{epsg, start, end} — 정본 칸 · 비면 첫 연도 시점 + 마지막 연도 종점으로 접음."""
|
||||
def _stored(project: dict[str, Any]) -> dict[str, Any] | None:
|
||||
stored = project.get("plan_base_json")
|
||||
if isinstance(stored, str):
|
||||
stored = json.loads(stored)
|
||||
return json.loads(stored) if isinstance(stored, str) else stored
|
||||
|
||||
|
||||
def base_view(project: dict[str, Any], rows: dict[int, dict[str, Any]]) -> dict[str, Any]:
|
||||
"""{epsg, starts, ends} — 정본 칸(옛 한 점 꼴은 감쌈) ·
|
||||
비면 첫 연도 시점 + 마지막 연도 종점으로 접음."""
|
||||
stored = _stored(project)
|
||||
if stored:
|
||||
epsg, start, end = stored.get("epsg"), stored.get("start"), stored.get("end")
|
||||
epsg = stored.get("epsg")
|
||||
starts = stored.get("starts", [stored["start"]] if stored.get("start") else [])
|
||||
ends = stored.get("ends", [stored["end"]] if stored.get("end") else [])
|
||||
elif rows:
|
||||
first, last = rows[min(rows)], rows[max(rows)]
|
||||
epsg = first.get("epsg")
|
||||
start = {"x": first.get("start_x"), "y": first.get("start_y")}
|
||||
end = {"x": last.get("end_x"), "y": last.get("end_y")}
|
||||
starts = [{"x": first.get("start_x"), "y": first.get("start_y")}]
|
||||
ends = [{"x": last.get("end_x"), "y": last.get("end_y")}]
|
||||
else:
|
||||
epsg, start, end = None, None, None
|
||||
return {
|
||||
"epsg": epsg,
|
||||
"start": _point(start["x"], start["y"], epsg) if start else None,
|
||||
"end": _point(end["x"], end["y"], epsg) if end else None,
|
||||
}
|
||||
epsg, starts, ends = None, [], []
|
||||
|
||||
def points(items: list[dict[str, Any]]) -> list[dict[str, float]]:
|
||||
return [p for p in (_point(i.get("x"), i.get("y"), epsg) for i in items) if p]
|
||||
|
||||
return {"epsg": epsg, "starts": points(starts), "ends": points(ends)}
|
||||
|
||||
|
||||
async def load_base(project_id: UUID) -> dict[str, Any]:
|
||||
"""전체 시점 · 종점 — D03 이 읽는 길."""
|
||||
"""전체 시점 · 종점 목록 — D03 이 읽는 길."""
|
||||
project, rows = await _load(project_id)
|
||||
return base_view(project, rows)
|
||||
|
||||
|
||||
def stored_base(payload: BaseRequest) -> dict[str, Any]:
|
||||
"""PUT 몸 → 정본 칸 꼴(TM m)."""
|
||||
given = [p for p in (payload.start, payload.end) if p]
|
||||
epsg = tm_epsg_for_lon(sum(p.lon for p in given) / len(given)) if given else None
|
||||
def stored_base(payload: BaseRequest, epsg: int | None = None) -> dict[str, Any]:
|
||||
"""PUT 몸 → 정본 칸 꼴(TM m) — 좌표계 = `epsg`(저장된 값) · 없으면 점 평균 경도의 TM 띠."""
|
||||
given = [*payload.starts, *payload.ends]
|
||||
if not given:
|
||||
epsg = None
|
||||
elif epsg is None:
|
||||
epsg = tm_epsg_for_lon(sum(p.lon for p in given) / len(given))
|
||||
|
||||
def tm(p: LonLat | None) -> dict[str, float] | None:
|
||||
if p is None or epsg is None:
|
||||
return None
|
||||
x, y = lonlat_to_tm(p.lon, p.lat, epsg)
|
||||
return {"x": x, "y": y}
|
||||
def tm(items: list[LonLat]) -> list[dict[str, float]]:
|
||||
return [dict(zip(("x", "y"), lonlat_to_tm(p.lon, p.lat, epsg))) for p in items]
|
||||
|
||||
return {"epsg": epsg, "start": tm(payload.start), "end": tm(payload.end)}
|
||||
return {"epsg": epsg, "starts": tm(payload.starts), "ends": tm(payload.ends)}
|
||||
|
||||
|
||||
async def _store_base(project_id: UUID, base: dict[str, Any]) -> None:
|
||||
@@ -221,6 +140,7 @@ async def put_plan_points(
|
||||
) -> dict[str, Any]:
|
||||
if not is_forest_service(session):
|
||||
raise HTTPException(status_code=403, detail="예정노선 지점은 산림청만 고칠 수 있습니다.")
|
||||
await _load(project_id) # 없음 · 예정노선 아님 거절
|
||||
await _store_base(project_id, stored_base(payload))
|
||||
project, _ = await _load(project_id) # 없음 · 예정노선 아님 거절
|
||||
epsg = (_stored(project) or {}).get("epsg")
|
||||
await _store_base(project_id, stored_base(payload, int(epsg) if epsg else None))
|
||||
return {"status": "success", **await load_base(project_id)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* D02_PlanBase_UI_Page.ts
|
||||
* D02 1단계 기준 지정(요구서 345 3-2 · 389 3장 1 · PLAN 560-2) — 메인 = 카카오맵 · 좌 = 전체 시점 · 종점.
|
||||
* D02 1단계 기준 지정(요구서 345 3-2 · 389 3장 1 · 440 1 · PLAN 560-2 · 579-2) — 메인 = 카카오맵 ·
|
||||
* 좌 = 시점 목록 · 종점 목록(짝 없이 따로 · 최소 한 줄씩 · 줄마다 🗑 · [+ 시점] [+ 종점]) · 점 잇는 선 없음.
|
||||
* 조작(끌기 · 지도 누름 · 경위도 입력) = 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈)
|
||||
* · 떠남(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) = 바뀐 것 있으면 공용 물음 [취소][폐기][저장](572-3)
|
||||
* · 자동저장 없음(CLAUDE.md 5장) · 캐시는 그때 서버 값(base)이 지금과 같을 때만 씀(D03 이 고친 값 우선).
|
||||
@@ -14,6 +15,7 @@ import "./D02_PlanBase_UI_Style.css";
|
||||
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
|
||||
import {
|
||||
createButton,
|
||||
createIconButton,
|
||||
createInputField,
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
@@ -46,9 +48,11 @@ interface ServerPoint extends LonLat {
|
||||
}
|
||||
interface PointsView {
|
||||
epsg: number | null;
|
||||
start: ServerPoint | null;
|
||||
end: ServerPoint | null;
|
||||
starts: ServerPoint[];
|
||||
ends: ServerPoint[];
|
||||
}
|
||||
type Side = "starts" | "ends";
|
||||
type Points = Record<Side, Array<LonLat | null>>;
|
||||
|
||||
const KOREA_CENTER: [number, number] = [127.8, 36.3];
|
||||
const DIGITS = 7; // 경위도 소수 7 자리 ≈ 1 cm
|
||||
@@ -66,11 +70,15 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/** 서버 두 점 → [시점, 종점] */
|
||||
function boundaryPoints(view: PointsView): Array<LonLat | null> {
|
||||
const pick = (p: ServerPoint | null): LonLat | null =>
|
||||
p ? { lon: +p.lon.toFixed(DIGITS), lat: +p.lat.toFixed(DIGITS) } : null;
|
||||
return [pick(view.start), pick(view.end)];
|
||||
const SIDES: Side[] = ["starts", "ends"];
|
||||
|
||||
/** 서버 목록 → 표 줄(빈 목록 = 빈 줄 하나) */
|
||||
function boundaryPoints(view: PointsView | null): Points {
|
||||
const pick = (list: ServerPoint[] = []): Array<LonLat | null> =>
|
||||
list.length
|
||||
? list.map((p) => ({ lon: +p.lon.toFixed(DIGITS), lat: +p.lat.toFixed(DIGITS) }))
|
||||
: [null];
|
||||
return { starts: pick(view?.starts), ends: pick(view?.ends) };
|
||||
}
|
||||
|
||||
export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
||||
@@ -99,16 +107,25 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
||||
});
|
||||
|
||||
let view: PointsView | null = null;
|
||||
let points: Array<LonLat | null> = [null, null];
|
||||
let active: number | null = null; // 지도 누름이 놓을 지점
|
||||
let points: Points = boundaryPoints(null);
|
||||
let active: string | null = null; // 지도 누름이 놓을 지점(`starts:0` 꼴)
|
||||
let editable = false; // 산림청만 고침
|
||||
|
||||
const pointName = (i: number): string => t(i === 0 ? "D02_Start" : "D02_End");
|
||||
const firstEmpty = (): number | null => {
|
||||
const i = points.findIndex((p) => !p);
|
||||
return i < 0 ? null : i;
|
||||
const keyOf = (side: Side, i: number): string => `${side}:${i}`;
|
||||
const fromKey = (key: string): [Side, number] => {
|
||||
const [side, i] = key.split(":");
|
||||
return [side as Side, Number(i)];
|
||||
};
|
||||
const serverPoints = (): Array<LonLat | null> => (view ? boundaryPoints(view) : [null, null]);
|
||||
const pointName = (side: Side, i: number): string =>
|
||||
`${t(side === "starts" ? "D02_Start" : "D02_End")} ${i + 1}`;
|
||||
const firstEmpty = (): string | null => {
|
||||
for (const side of SIDES) {
|
||||
const i = points[side].findIndex((p) => !p);
|
||||
if (i >= 0) return keyOf(side, i);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const serverPoints = (): Points => boundaryPoints(view);
|
||||
const dirty = (): boolean => JSON.stringify(points) !== JSON.stringify(serverPoints());
|
||||
const remember = (): void =>
|
||||
writeState("d02-points", { base: serverPoints(), points }, projectId);
|
||||
@@ -116,7 +133,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
||||
const map = editMap({
|
||||
hint: t("D02_Map_Hint"),
|
||||
center: () => KOREA_CENTER,
|
||||
onMove: (key, lon, lat) => place(Number(key), { lon, lat }),
|
||||
onMove: (key, lon, lat) => place(key, { lon, lat }),
|
||||
onClick: (lon, lat) => {
|
||||
if (!editable) return;
|
||||
if (active === null) return showToast(t("D02_Pick_Point"), "info");
|
||||
@@ -140,83 +157,123 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
||||
typePick.root.classList.add("d02-map__type");
|
||||
mapHost.append(map.root, typePick.root);
|
||||
|
||||
function place(index: number, at: LonLat): void {
|
||||
points[index] = { lon: +at.lon.toFixed(DIGITS), lat: +at.lat.toFixed(DIGITS) };
|
||||
if (index === active) active = firstEmpty(); // 놓은 지점 = 다음 빈 지점으로
|
||||
function place(key: string, at: LonLat): void {
|
||||
const [side, i] = fromKey(key);
|
||||
points[side][i] = { lon: +at.lon.toFixed(DIGITS), lat: +at.lat.toFixed(DIGITS) };
|
||||
if (key === active) active = firstEmpty(); // 놓은 지점 = 다음 빈 지점으로
|
||||
remember();
|
||||
paint(String(index));
|
||||
paint(key);
|
||||
}
|
||||
|
||||
/** focus = 고친 지점(보기 밖이면 그리로) · 빈 값 = 보기 그대로 */
|
||||
/** [+ 시점] · [+ 종점] — 빈 줄을 더하고 지도 누름이 거기 놓음. */
|
||||
function addRow(side: Side): void {
|
||||
points[side].push(null);
|
||||
active = keyOf(side, points[side].length - 1);
|
||||
remember();
|
||||
paint();
|
||||
}
|
||||
|
||||
/** 🗑 — 줄을 지움(마지막 한 줄이면 비움 · 최소 한 줄). */
|
||||
function removeRow(side: Side, i: number): void {
|
||||
if (points[side].length > 1) points[side].splice(i, 1);
|
||||
else points[side][0] = null;
|
||||
active = firstEmpty();
|
||||
remember();
|
||||
paint();
|
||||
}
|
||||
|
||||
/** focus = 고친 지점(보기 밖이면 그리로) · 빈 값 = 보기 그대로 · 점 잇는 선 없음(요구 440 1). */
|
||||
function drawMap(focus = ""): void {
|
||||
const pins: MapPin[] = points.flatMap((p, i) =>
|
||||
p ? [{ key: String(i), name: pointName(i), lon: p.lon, lat: p.lat, movable: editable }] : [],
|
||||
const pins: MapPin[] = SIDES.flatMap((side) =>
|
||||
points[side].flatMap((p, i) =>
|
||||
p
|
||||
? [
|
||||
{
|
||||
key: keyOf(side, i),
|
||||
name: pointName(side, i),
|
||||
lon: p.lon,
|
||||
lat: p.lat,
|
||||
movable: editable,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const [a, b] = points;
|
||||
const lines: Array<Array<[number, number]>> =
|
||||
a && b
|
||||
? [
|
||||
[
|
||||
[a.lat, a.lon],
|
||||
[b.lat, b.lon],
|
||||
],
|
||||
]
|
||||
: [];
|
||||
map.set(pins, lines, undefined, focus);
|
||||
map.set(pins, [], undefined, focus);
|
||||
}
|
||||
|
||||
function pointRow(side: Side, p: LonLat | null, i: number): HTMLElement {
|
||||
const lon = createInputField({ type: "number", placeholder: t("D02_Lon"), size: 24 }).input;
|
||||
const lat = createInputField({ type: "number", placeholder: t("D02_Lat"), size: 24 }).input;
|
||||
lon.classList.add("d02-points__input");
|
||||
lat.classList.add("d02-points__input");
|
||||
lon.step = lat.step = "any";
|
||||
lon.value = p ? String(p.lon) : "";
|
||||
lat.value = p ? String(p.lat) : "";
|
||||
lon.disabled = lat.disabled = !editable;
|
||||
lon.dataset.point = lat.dataset.point = keyOf(side, i);
|
||||
lon.dataset.axis = "lon";
|
||||
lat.dataset.axis = "lat";
|
||||
const typed = (): void => {
|
||||
const at = { lon: Number.parseFloat(lon.value), lat: Number.parseFloat(lat.value) };
|
||||
const ok = at.lon >= 120 && at.lon <= 135 && at.lat >= 30 && at.lat <= 45;
|
||||
for (const box of [lon, lat]) {
|
||||
box.classList.toggle("is-error", !ok);
|
||||
if (ok) box.removeAttribute("aria-invalid");
|
||||
else box.setAttribute("aria-invalid", "true");
|
||||
}
|
||||
if (ok) place(keyOf(side, i), at);
|
||||
};
|
||||
lon.addEventListener("change", typed);
|
||||
lat.addEventListener("change", typed);
|
||||
const pick = createButton({
|
||||
label: "📍",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
active = keyOf(side, i);
|
||||
drawTable();
|
||||
},
|
||||
});
|
||||
pick.title = pick.ariaLabel = t("D02_Pick_Point");
|
||||
pick.hidden = !editable;
|
||||
const drop = createIconButton({
|
||||
icon: "trash",
|
||||
title: t("D02_Remove_Point"),
|
||||
size: 24,
|
||||
onClick: () => removeRow(side, i),
|
||||
});
|
||||
drop.hidden = !editable;
|
||||
const row = el("div", {
|
||||
className: `d02-points__row${active === keyOf(side, i) ? " is-active" : ""}`,
|
||||
children: [
|
||||
el("span", { className: "d02-points__name", text: pointName(side, i) }),
|
||||
pick,
|
||||
lon,
|
||||
lat,
|
||||
drop,
|
||||
],
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function drawTable(): void {
|
||||
const rows = points.map((p, i) => {
|
||||
const lon = createInputField({ type: "number", placeholder: t("D02_Lon"), size: 24 }).input;
|
||||
const lat = createInputField({ type: "number", placeholder: t("D02_Lat"), size: 24 }).input;
|
||||
lon.classList.add("d02-points__input");
|
||||
lat.classList.add("d02-points__input");
|
||||
lon.step = lat.step = "any";
|
||||
lon.value = p ? String(p.lon) : "";
|
||||
lat.value = p ? String(p.lat) : "";
|
||||
lon.disabled = lat.disabled = !editable;
|
||||
lon.dataset.point = lat.dataset.point = String(i);
|
||||
lon.dataset.axis = "lon";
|
||||
lat.dataset.axis = "lat";
|
||||
const typed = (): void => {
|
||||
const at = { lon: Number.parseFloat(lon.value), lat: Number.parseFloat(lat.value) };
|
||||
const ok = at.lon >= 120 && at.lon <= 135 && at.lat >= 30 && at.lat <= 45;
|
||||
for (const box of [lon, lat]) {
|
||||
box.classList.toggle("is-error", !ok);
|
||||
if (ok) box.removeAttribute("aria-invalid");
|
||||
else box.setAttribute("aria-invalid", "true");
|
||||
}
|
||||
if (ok) place(i, at);
|
||||
};
|
||||
lon.addEventListener("change", typed);
|
||||
lat.addEventListener("change", typed);
|
||||
const pick = createButton({
|
||||
label: "📍",
|
||||
const rows = SIDES.flatMap((side) => {
|
||||
const add = createButton({
|
||||
label: t(side === "starts" ? "D02_Add_Start" : "D02_Add_End"),
|
||||
iconStart: "plus",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
active = i;
|
||||
drawTable();
|
||||
},
|
||||
size: 24,
|
||||
onClick: () => addRow(side),
|
||||
});
|
||||
pick.title = pick.ariaLabel = t("D02_Pick_Point");
|
||||
pick.hidden = !editable;
|
||||
const row = el("div", {
|
||||
className: `d02-points__row${active === i ? " is-active" : ""}`,
|
||||
children: [
|
||||
el("span", { className: "d02-points__name", text: pointName(i) }),
|
||||
pick,
|
||||
lon,
|
||||
lat,
|
||||
],
|
||||
});
|
||||
return row;
|
||||
add.hidden = !editable;
|
||||
return [...points[side].map((p, i) => pointRow(side, p, i)), el("div", { children: [add] })];
|
||||
});
|
||||
table.replaceChildren(...rows);
|
||||
const saved = view?.start;
|
||||
const count = (side: Side): number => view?.[side].length ?? 0;
|
||||
info.textContent = view?.epsg
|
||||
? `${t("D02_Saved_Crs")} EPSG:${view.epsg}` +
|
||||
(saved ? ` · X ${saved.x.toFixed(3)} Y ${saved.y.toFixed(3)}` : "")
|
||||
? `${t("D02_Start")} ${count("starts")} · ${t("D02_End")} ${count("ends")} · ` +
|
||||
`${t("D02_Saved_Crs")} EPSG:${view.epsg}`
|
||||
: t("D02_Not_Saved");
|
||||
}
|
||||
|
||||
@@ -233,7 +290,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
||||
async function store(): Promise<boolean> {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const body = { start: points[0], end: points[1] };
|
||||
const body = { starts: points.starts.filter(Boolean), ends: points.ends.filter(Boolean) };
|
||||
adopt(
|
||||
await request<PointsView>(`/projects/${projectId}/plan/points`, {
|
||||
method: "PUT",
|
||||
@@ -262,12 +319,11 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
|
||||
if (mapHint) mapHint.textContent = t("D02_View_Only");
|
||||
}
|
||||
adopt(await request<PointsView>(`/projects/${projectId}/plan/points`));
|
||||
const cached = readState<{
|
||||
base?: Array<LonLat | null>;
|
||||
points?: Array<LonLat | null>;
|
||||
}>("d02-points", projectId);
|
||||
const cached = readState<{ base?: Points; points?: Points }>("d02-points", projectId);
|
||||
const sameBase = JSON.stringify(cached?.base) === JSON.stringify(points);
|
||||
if (editable && sameBase && cached?.points?.length === 2) points = cached.points;
|
||||
// 옛 캐시(두 칸 배열)는 꼴이 달라 버림
|
||||
if (editable && sameBase && cached?.points?.starts?.length && cached.points.ends?.length)
|
||||
points = cached.points;
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : String(error), "error");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* D02 기준 지정(529-3 · 560-2) — 메인 지도 = 본문 높이 가득 · 좌 시점 · 종점 표 */
|
||||
/* D02 기준 지정(529-3 · 560-2 · 579-2) — 메인 지도 = 본문 높이 가득 · 좌 시점 목록 · 종점 목록 */
|
||||
.d02-map {
|
||||
position: relative;
|
||||
height: calc(100dvh - var(--app-header-h));
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
.d02-points__row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr 1fr;
|
||||
grid-template-columns: auto 1fr 1fr auto;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 4px 6px;
|
||||
|
||||
@@ -19,11 +19,11 @@ export interface BasePoint {
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** D02 정본 — 전체 시점 · 종점(작업 좌표계 m + 경위도). */
|
||||
/** D02 정본 — 시점 목록 · 종점 목록(작업 좌표계 m + 경위도 · 579-1). */
|
||||
export interface PlanBaseView {
|
||||
epsg: number | null;
|
||||
start: BasePoint | null;
|
||||
end: BasePoint | null;
|
||||
starts: BasePoint[];
|
||||
ends: BasePoint[];
|
||||
}
|
||||
|
||||
export interface PlanJob {
|
||||
|
||||
@@ -391,7 +391,7 @@ async def _freeze_base(connection: Any, project_id: UUID, base: dict[str, Any])
|
||||
"""옛 연도 행을 접은 시점 · 종점 → D02 정본 칸(비었을 때만)."""
|
||||
stored = {
|
||||
"epsg": base["epsg"],
|
||||
**{key: {"x": base[key]["x"], "y": base[key]["y"]} for key in ("start", "end")},
|
||||
**{key: [{"x": p["x"], "y": p["y"]} for p in base[key]] for key in ("starts", "ends")},
|
||||
}
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
@@ -426,7 +426,7 @@ async def put_plan_network(
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
await connection.begin()
|
||||
try:
|
||||
if project["plan_base_json"] is None and base["start"] and base["end"]:
|
||||
if project["plan_base_json"] is None and base["starts"] and base["ends"]:
|
||||
await _freeze_base(connection, project_id, base)
|
||||
await _store(connection, project_id, network, derived)
|
||||
await connection.commit()
|
||||
|
||||
@@ -48,9 +48,9 @@ class TerrainBody(BaseModel):
|
||||
|
||||
|
||||
async def _plan_ends(project_id: UUID) -> tuple[Path, int, list[tuple[float, float]]]:
|
||||
"""(실경로, 좌표계, [전체 시점, 전체 종점]) — D02 정본(빈 칸이면 옛 연도 행 접음)."""
|
||||
"""(실경로, 좌표계, [전체 시점들 · 종점들]) — D02 정본(빈 칸이면 옛 연도 행 접음)."""
|
||||
plan = await base.load_base(project_id)
|
||||
if not plan["start"] or not plan["end"]:
|
||||
if not plan["starts"] or not plan["ends"]:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="전체 시점 · 종점이 없습니다 — 1단계에서 지정하세요."
|
||||
)
|
||||
@@ -58,7 +58,7 @@ async def _plan_ends(project_id: UUID) -> tuple[Path, int, list[tuple[float, flo
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
if not stored:
|
||||
raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.")
|
||||
ends = [(float(p["x"]), float(p["y"])) for p in (plan["start"], plan["end"])]
|
||||
ends = [(float(p["x"]), float(p["y"])) for p in (*plan["starts"], *plan["ends"])]
|
||||
return Path(resolve_stored_project_path(stored)), int(plan["epsg"]), ends
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Draw.ts
|
||||
* D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 요구서 389 3장 8~10) — 시점에서 점을 찍어 직선으로 이음.
|
||||
* D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 580 · 요구서 389 3장 8~10 · 440 3~7) — 점을 찍어 직선으로 이음.
|
||||
*
|
||||
* 본선 = D02 시점에서 · D02 종점 둘레(SNAP_PX)에 오면 종점에 붙고 그 점에서 끝.
|
||||
* 미리보기 = 마지막 점 → 마우스 길이(m) · 원지반 기울기(%, + 오르막) — 제약 칸 넷:
|
||||
* 자유 · 길이 고정 · 기울기 고정(마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중 마우스에 가까운 것) ·
|
||||
* 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈) · 미리보기 값은 + 오르막 / − 내리막.
|
||||
* 갈래 = 기존 선 꺾임점(선 위면 꺾임점 끼움)을 눌러 시작 · 구역 마크 = 선 위 누름(오른쪽 = 지움).
|
||||
* [긋기] 하나 — 첫 누름 자리로 시작: 시점 = 새 본선 · 종점 = 새 본선(끝낼 때 뒤집어 종점이 선 끝 ·
|
||||
* 측점 No.0 = 반대쪽) · 선 꼬리 = 이어 긋기 · 갈래 없는 선 머리 = 뒤집어 이어 긋고 끝낼 때 되돌림 ·
|
||||
* 선 위 · 꺾임점 = 갈래. 시점 · 종점 · 다른 선 둘레(SNAP_PX)에 오면 거기 붙고 그 점에서 끝 —
|
||||
* 머리가 다른 선 꼬리에 닿으면 한 선으로 잇고 · 꺾임점에 닿으면 그 점의 갈래.
|
||||
* 미리보기 = 마지막 점 → 마우스 길이(m) · 가상 종단 기울기(원지반 % · ° · + 오르막) — 제약 칸 넷:
|
||||
* 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중
|
||||
* 마우스에 가까운 것) · 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈).
|
||||
* 긋는 해 = 새 조각 처음 해 · 긋는 중 해를 바꾸면 다음 점부터 그 해(지금 끝 자리에 연도 구분 마크).
|
||||
* 연도 구분 = 선 위 누름(마크 · 뒤 조각 고름 → 페이지가 연도 고름을 엶) · 오른쪽 = 지움.
|
||||
* 고르기 · 고치기(쉴 때) = `D03_PlanAnalysis_UI_Edit`.
|
||||
* 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기(설정 창 단축키 표 D03 줄).
|
||||
* 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`) · 꼴 다루기 = `logic/D03_PlanAnalysis_Plan` ·
|
||||
* 값을 바꾸면 `commit(다음 꼴)` 한 번 = 되돌리기 한 걸음(스택 · 캐시 = 페이지).
|
||||
@@ -16,50 +21,49 @@ import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { createSegment } from "@ui/ui_template_segment";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
||||
import {
|
||||
compassCandidates,
|
||||
compassPoint,
|
||||
distanceAlong,
|
||||
fixedLengthPoint,
|
||||
fixedSlopePoint,
|
||||
groundSlopePct,
|
||||
lineLength,
|
||||
pointAtDistance,
|
||||
segmentLength,
|
||||
stationChainages,
|
||||
} from "./logic/D03_PlanAnalysis_Draw";
|
||||
import {
|
||||
addBranch,
|
||||
appendVertex,
|
||||
dropLastLine,
|
||||
attachHead,
|
||||
dropLastVertex,
|
||||
insertVertex,
|
||||
reachedEnd,
|
||||
newLine,
|
||||
normalizePieces,
|
||||
removeLine,
|
||||
removeMark,
|
||||
reverseLine,
|
||||
samePoint,
|
||||
setPieceYear,
|
||||
splitPiece,
|
||||
type PlanNetwork,
|
||||
type Vertex,
|
||||
} from "./logic/D03_PlanAnalysis_Plan";
|
||||
import {
|
||||
planColors,
|
||||
PLAN_STATION_INTERVAL_M,
|
||||
type PlanMapLayer,
|
||||
type ScreenMap,
|
||||
} from "./D03_PlanAnalysis_UI_Map";
|
||||
fmt,
|
||||
lineAt,
|
||||
paintNetwork,
|
||||
paintPreview,
|
||||
screenGap,
|
||||
slopeDeg,
|
||||
SNAP_PX,
|
||||
} from "./D03_PlanAnalysis_UI_DrawPaint";
|
||||
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||||
|
||||
/** 종점 · 꺾임점에 붙는 반경(px). */
|
||||
const SNAP_PX = 12;
|
||||
/** 선 위 누름으로 볼 반경(px). */
|
||||
const LINE_HIT_PX = 8;
|
||||
/** 구간 길이 라벨을 보일 화면 길이(px). */
|
||||
const LABEL_MIN_PX = 70;
|
||||
/** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */
|
||||
export const DRAW_DEFAULT_LENGTH_M = 20;
|
||||
export const DRAW_DEFAULT_SLOPE_PCT = 9;
|
||||
|
||||
export type DrawConstraint = "free" | "length" | "slope" | "both";
|
||||
export type DrawMode = "idle" | "draw" | "branch" | "mark";
|
||||
export type DrawMode = "idle" | "draw" | "mark";
|
||||
|
||||
export interface DrawPreview {
|
||||
point: Vertex;
|
||||
@@ -69,15 +73,21 @@ export interface DrawPreview {
|
||||
note: string | null;
|
||||
blocked: boolean;
|
||||
snapped: boolean;
|
||||
/** 다른 선 위(꺾임점 아님)에 붙음 — 점을 찍을 때 그 선에 꺾임점을 끼움. */
|
||||
onLine: { line: number; segment: number } | null;
|
||||
}
|
||||
|
||||
export interface PlanDrawParams {
|
||||
base: () => { start: Vertex; end: Vertex };
|
||||
base: () => { starts: Vertex[]; ends: Vertex[] };
|
||||
network: () => PlanNetwork;
|
||||
heightAt: () => (x: number, y: number) => number;
|
||||
commit: (next: PlanNetwork) => void;
|
||||
/** 긋는 해 — 새 조각 처음 해 · 긋는 중 바꾸면 다음 점부터. */
|
||||
year: () => number;
|
||||
/** 모드 · 그리는 선이 바뀜 — 페이지가 단추 · 안내를 다시. */
|
||||
onMode: () => void;
|
||||
/** 연도 구분 마크를 놓음 — 뒤 조각(id) 고름 · 연도 고름. */
|
||||
onMark: (piece: string) => void;
|
||||
}
|
||||
|
||||
export interface PlanDraw {
|
||||
@@ -91,21 +101,18 @@ export interface PlanDraw {
|
||||
key: (event: KeyboardEvent) => boolean;
|
||||
/** 되돌리기 뒤 — 그리던 선이 사라졌으면 그리기를 멈춤. */
|
||||
sync: () => void;
|
||||
/** 검증 창구 — 지금 미리보기 · 제약. */
|
||||
/** 검증 창구 — 지금 미리보기 · 제약 · 그리는 선(차례 · 끝에서 긋는 중). */
|
||||
preview: () => DrawPreview | null;
|
||||
stroke: () => { line: number; flip: boolean } | null;
|
||||
constraint: () => { kind: DrawConstraint; lengthM: number; slopePct: number };
|
||||
setConstraint: (kind: DrawConstraint, lengthM?: number, slopePct?: number) => void;
|
||||
}
|
||||
|
||||
const fmt = (value: number): string => value.toFixed(1);
|
||||
const signed = (value: number): string => `${value >= 0 ? "+" : ""}${value.toFixed(1)}`;
|
||||
const near = (a: [number, number], b: [number, number], px: number): boolean =>
|
||||
Math.hypot(a[0] - b[0], a[1] - b[1]) <= px;
|
||||
|
||||
export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
let mode: DrawMode = "idle";
|
||||
let editable = false;
|
||||
let active: number | null = null; // 그리는 선 차례
|
||||
let active: number | null = null; // 그리는 선 차례(첫 누름 전 = null)
|
||||
let flip = false; // 끝에서 긋는 중 — 끝낼 때 뒤집음
|
||||
let strokeStart: PlanNetwork | null = null; // Esc 로 돌아갈 꼴
|
||||
let preview: DrawPreview | null = null;
|
||||
let candidates: Vertex[] = [];
|
||||
@@ -114,27 +121,21 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
|
||||
|
||||
// ── 좌측 칸 ─────────────────────────────────────────────
|
||||
const mainButton = createButton({
|
||||
label: "본선 긋기",
|
||||
const drawButton = createButton({
|
||||
label: "긋기",
|
||||
variant: "filled",
|
||||
size: 28,
|
||||
onClick: () => setMode(mode === "draw" && active === 0 ? "idle" : "draw"),
|
||||
});
|
||||
const branchButton = createButton({
|
||||
label: "갈래",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () => setMode(mode === "branch" ? "idle" : "branch"),
|
||||
onClick: () => setMode(mode === "draw" ? "idle" : "draw"),
|
||||
});
|
||||
const markButton = createButton({
|
||||
label: "구역 마크",
|
||||
label: "연도 구분",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () => setMode(mode === "mark" ? "idle" : "mark"),
|
||||
});
|
||||
const modeRow = document.createElement("div");
|
||||
modeRow.className = "d03-plan__row";
|
||||
modeRow.append(mainButton, branchButton, markButton);
|
||||
modeRow.append(drawButton, markButton);
|
||||
const kindSegment = createSegment({
|
||||
options: [
|
||||
{ value: "free", label: "자유" },
|
||||
@@ -173,14 +174,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
size: 24,
|
||||
onInput: (value) => {
|
||||
const next = Number(value);
|
||||
if (value.trim() !== "" && Number.isFinite(next)) slopePct = next;
|
||||
slopeField.setError(value.trim() !== "" && Number.isFinite(next) ? undefined : "숫자");
|
||||
const ok = value.trim() !== "" && Number.isFinite(next);
|
||||
if (ok) slopePct = next;
|
||||
slopeField.setError(ok ? undefined : "숫자");
|
||||
degText();
|
||||
},
|
||||
});
|
||||
slopeField.input.dataset.role = "draw-slope";
|
||||
const slopeDegText = createText("", "muted");
|
||||
slopeDegText.dataset.role = "draw-slope-deg";
|
||||
const degText = (): void => {
|
||||
slopeDegText.textContent = `≈ ${fmt(slopeDeg(Math.abs(slopePct)))}°`;
|
||||
};
|
||||
degText();
|
||||
const fieldRow = document.createElement("div");
|
||||
fieldRow.className = "d03-plan__row";
|
||||
fieldRow.append(lengthField.root, slopeField.root);
|
||||
fieldRow.append(lengthField.root, slopeField.root, slopeDegText);
|
||||
const hint = createText("", "muted");
|
||||
hint.classList.add("d03-plan__hint");
|
||||
const panel = document.createElement("div");
|
||||
@@ -194,26 +203,21 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
|
||||
function panelState(): void {
|
||||
const network = params.network();
|
||||
const done = reachedEnd(network, params.base().end);
|
||||
mainButton.querySelector(".ui-btn__label")!.textContent =
|
||||
mode === "draw" && active === 0 ? "긋기 끝" : done ? "본선 끝남" : "본선 긋기";
|
||||
mainButton.disabled = !editable || (done && !(mode === "draw" && active === 0));
|
||||
branchButton.disabled = !editable || lineLength(network.lines[0]?.vertices ?? []) <= 0;
|
||||
markButton.disabled = branchButton.disabled;
|
||||
branchButton.setAttribute("aria-pressed", String(mode === "branch"));
|
||||
drawButton.querySelector(".ui-btn__label")!.textContent = mode === "draw" ? "긋기 끝" : "긋기";
|
||||
drawButton.disabled = !editable;
|
||||
markButton.disabled = !editable || !network.lines.some((line) => lineLength(line.vertices) > 0);
|
||||
drawButton.setAttribute("aria-pressed", String(mode === "draw"));
|
||||
markButton.setAttribute("aria-pressed", String(mode === "mark"));
|
||||
panel.dataset.mode = mode;
|
||||
hint.textContent = !editable
|
||||
? "보기만 — 산림청 계정에서 긋습니다."
|
||||
: mode === "draw"
|
||||
? "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기"
|
||||
: mode === "branch"
|
||||
? "갈래를 시작할 선 위 점을 누르세요."
|
||||
: mode === "mark"
|
||||
? "선 위를 눌러 구역을 나눕니다 · 오른쪽 누름 = 마크 지움"
|
||||
: done
|
||||
? "본선이 종점에 닿았습니다 — 갈래 · 구역 마크 · 연도를 정하세요."
|
||||
: "[본선 긋기]로 시점에서 점을 찍어 갑니다.";
|
||||
? active === null
|
||||
? "시점 · 종점 · 선 끝 · 선 위를 눌러 시작합니다."
|
||||
: "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기"
|
||||
: mode === "mark"
|
||||
? "선 위를 눌러 연도를 나눕니다 · 오른쪽 누름 = 마크 지움"
|
||||
: "[긋기]로 시점 · 종점 · 선에서 긋습니다 · 선을 누르면 고릅니다.";
|
||||
}
|
||||
fieldsState();
|
||||
|
||||
@@ -223,20 +227,46 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
return vertices[vertices.length - 1] ?? null;
|
||||
};
|
||||
|
||||
/** 마우스 자리 → 찍을 점 · 값 — 종점 붙기가 제약보다 먼저(본선만). */
|
||||
/** 붙을 자리 — 앞에서 = 종점 · 끝에서 = 시점 · 다른 선 꺾임점 · 다른 선 위(그리는 선 빼고). */
|
||||
function snapAt(
|
||||
px: number,
|
||||
py: number,
|
||||
map: ScreenMap,
|
||||
): { point: Vertex; note: string; onLine: DrawPreview["onLine"] } | null {
|
||||
const { starts, ends } = params.base();
|
||||
const network = params.network();
|
||||
const offers = (flip ? starts : ends).map((point) => ({
|
||||
point,
|
||||
note: flip ? "시점에 붙음" : "종점에 붙음",
|
||||
}));
|
||||
network.lines.forEach((line, i) => {
|
||||
if (i !== active)
|
||||
for (const point of line.vertices) offers.push({ point, note: "선 꺾임점에 붙음" });
|
||||
});
|
||||
const best = offers
|
||||
.map((offer) => ({ ...offer, gap: screenGap(map, offer.point, px, py) }))
|
||||
.filter((offer) => offer.gap <= SNAP_PX)
|
||||
.sort((a, b) => a.gap - b.gap)[0];
|
||||
if (best) return { point: best.point, note: best.note, onLine: null };
|
||||
const hit = lineAt(network, px, py, map, active);
|
||||
return hit
|
||||
? { point: hit.point, note: "선 위에 붙음", onLine: { line: hit.line, segment: hit.segment } }
|
||||
: null;
|
||||
}
|
||||
|
||||
/** 마우스 자리 → 찍을 점 · 값 — 붙기가 제약보다 먼저. */
|
||||
function previewAt(px: number, py: number, map: ScreenMap): DrawPreview | null {
|
||||
if (active === null) return null;
|
||||
const from = lastOf(active);
|
||||
if (!from) return null;
|
||||
const z = params.heightAt();
|
||||
const cursor = map.toMetric(px, py);
|
||||
const end = params.base().end;
|
||||
candidates = kind === "both" ? compassCandidates(from, lengthM, slopePct, z) : [];
|
||||
const snap = snapAt(px, py, map);
|
||||
let point: Vertex | null = cursor;
|
||||
let note: string | null = null;
|
||||
let snapped = false;
|
||||
if (active === 0 && near(map.toScreen(end), [px, py], SNAP_PX)) {
|
||||
[point, note, snapped] = [end, "종점에 붙음", true];
|
||||
if (snap) {
|
||||
[point, note] = [snap.point, snap.note];
|
||||
} else if (kind === "length") {
|
||||
point = fixedLengthPoint(from, cursor, lengthM);
|
||||
} else if (kind === "slope") {
|
||||
@@ -254,56 +284,39 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
slopePct: groundSlopePct(from, at, z),
|
||||
note,
|
||||
blocked: !point || length <= 0,
|
||||
snapped,
|
||||
snapped: !!snap,
|
||||
onLine: snap?.onLine ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 화면 px 둘레 선 위 자리 — 가장 가까운 선 · 누가 · 꺾임점(가까우면) · 구간. */
|
||||
function lineAt(
|
||||
px: number,
|
||||
py: number,
|
||||
map: ScreenMap,
|
||||
): { line: number; atM: number; point: Vertex; vertex: number | null; segment: number } | null {
|
||||
const cursor = map.toMetric(px, py);
|
||||
let best: ReturnType<typeof lineAt> = null;
|
||||
let bestPx = LINE_HIT_PX;
|
||||
params.network().lines.forEach((line, index) => {
|
||||
if (line.vertices.length < 2) return;
|
||||
// 붙는 반경 안 꺾임점 중 가장 가까운 것
|
||||
let vertex = -1;
|
||||
let vertexPx = SNAP_PX;
|
||||
line.vertices.forEach((v, i) => {
|
||||
const [vx, vy] = map.toScreen(v);
|
||||
const d = Math.hypot(vx - px, vy - py);
|
||||
if (d <= vertexPx) [vertex, vertexPx] = [i, d];
|
||||
});
|
||||
const atM =
|
||||
vertex >= 0
|
||||
? lineLength(line.vertices.slice(0, vertex + 1))
|
||||
: distanceAlong(line.vertices, cursor);
|
||||
const point = vertex >= 0 ? line.vertices[vertex] : pointAtDistance(line.vertices, atM);
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
const gap = vertex >= 0 ? 0 : Math.hypot(sx - px, sy - py);
|
||||
if (gap > bestPx || (best && gap === bestPx)) return;
|
||||
// 누가가 든 구간(꺾임점 끼울 자리)
|
||||
let segment = 0;
|
||||
for (let walked = 0; segment < line.vertices.length - 2; segment += 1) {
|
||||
walked += segmentLength(line.vertices[segment], line.vertices[segment + 1]);
|
||||
if (walked >= atM) break;
|
||||
}
|
||||
best = { line: index, atM, point, vertex: vertex >= 0 ? vertex : null, segment };
|
||||
bestPx = gap;
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
// ── 모드 ────────────────────────────────────────────────
|
||||
/** 이번 긋기 마침 — 점 하나뿐인 선 버림 · 끝에서 그은 선은 뒤집고 머리 잇기 · 꼬리가 다른 선 머리면 잇기. */
|
||||
function stop(): void {
|
||||
// 점 하나뿐인 갈래는 버림(본선 시점은 남김)
|
||||
const network = params.network();
|
||||
if (active !== null && active > 0 && (network.lines[active]?.vertices.length ?? 0) < 2)
|
||||
params.commit(dropLastLine(network));
|
||||
let network = params.network();
|
||||
const line = active;
|
||||
const entry = line === null ? null : network.lines[line];
|
||||
if (line !== null && entry) {
|
||||
if (entry.vertices.length < 2) {
|
||||
const dropped = removeLine(network, line);
|
||||
if (typeof dropped !== "string") network = dropped;
|
||||
} else if (flip) {
|
||||
const turned = reverseLine(network, line);
|
||||
if (typeof turned !== "string") network = attachHead(turned, line);
|
||||
} else {
|
||||
const tail = entry.vertices[entry.vertices.length - 1];
|
||||
const next = network.lines.findIndex(
|
||||
(other, i) =>
|
||||
i !== line &&
|
||||
!other.branch &&
|
||||
other.vertices.length > 1 &&
|
||||
samePoint(other.vertices[0], tail),
|
||||
);
|
||||
if (next >= 0) network = attachHead(network, next);
|
||||
}
|
||||
if (network !== params.network()) params.commit(network);
|
||||
}
|
||||
active = null;
|
||||
flip = false;
|
||||
strokeStart = null;
|
||||
preview = null;
|
||||
candidates = [];
|
||||
@@ -312,20 +325,93 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
function setMode(next: DrawMode): void {
|
||||
if (mode === "draw") stop();
|
||||
mode = editable ? next : "idle";
|
||||
if (mode === "draw") {
|
||||
const network = params.network();
|
||||
if (reachedEnd(network, params.base().end)) {
|
||||
showToast("본선이 이미 종점에 닿았습니다 — 갈래는 [갈래]로 긋습니다.", "info");
|
||||
mode = "idle";
|
||||
} else {
|
||||
active = 0;
|
||||
strokeStart = network;
|
||||
}
|
||||
}
|
||||
panelState();
|
||||
params.onMode();
|
||||
}
|
||||
|
||||
/** [긋기] 첫 누름 — 선 끝 이어 긋기 · 종점(뒤집어 긋기) · 시점 · 선 위 갈래. 참 = 시작함. */
|
||||
function begin(px: number, py: number, map: ScreenMap): boolean {
|
||||
const network = params.network();
|
||||
const { starts, ends } = params.base();
|
||||
const gap = (point: Vertex): number => screenGap(map, point, px, py);
|
||||
const nearest = <T extends { gap: number }>(items: T[]): T | undefined =>
|
||||
items.filter((item) => item.gap <= SNAP_PX).sort((a, b) => a.gap - b.gap)[0];
|
||||
const onBase = (list: Vertex[], point: Vertex): boolean =>
|
||||
list.some((p) => samePoint(p, point));
|
||||
strokeStart = network;
|
||||
// 1) 선 끝 — 꼬리(종점에 닿은 꼬리 빼고) · 갈래 없는 선 머리(시점에 놓인 머리 빼고)
|
||||
const tips: Array<{ line: number; head: boolean; gap: number }> = [];
|
||||
network.lines.forEach((line, i) => {
|
||||
const tail = line.vertices[line.vertices.length - 1];
|
||||
const head = line.vertices[0];
|
||||
if (tail && !onBase(ends, tail)) tips.push({ line: i, head: false, gap: gap(tail) });
|
||||
if (line.vertices.length > 1 && !line.branch && !onBase(starts, head))
|
||||
tips.push({ line: i, head: true, gap: gap(head) });
|
||||
});
|
||||
const tip = nearest(tips);
|
||||
if (tip) {
|
||||
if (tip.head) {
|
||||
const turned = reverseLine(network, tip.line);
|
||||
if (typeof turned === "string") {
|
||||
showToast(turned, "info");
|
||||
return false;
|
||||
}
|
||||
params.commit(turned);
|
||||
}
|
||||
[active, flip] = [tip.line, tip.head];
|
||||
return true;
|
||||
}
|
||||
// 2) 시점 · 종점
|
||||
const base = nearest([
|
||||
...starts.map((point) => ({ point, fromEnd: false, gap: gap(point) })),
|
||||
...ends.map((point) => ({ point, fromEnd: true, gap: gap(point) })),
|
||||
]);
|
||||
if (base) {
|
||||
const { point, fromEnd } = base;
|
||||
const owner = fromEnd
|
||||
? -1
|
||||
: network.lines.findIndex((line) => line.vertices.some((v) => samePoint(v, point)));
|
||||
if (owner >= 0) {
|
||||
// 이미 선이 놓인 시점 = 그 점의 갈래
|
||||
const vertex = network.lines[owner].vertices.findIndex((v) => samePoint(v, point));
|
||||
params.commit(addBranch(network, owner, vertex));
|
||||
} else params.commit(newLine(network, point));
|
||||
[active, flip] = [params.network().lines.length - 1, fromEnd];
|
||||
return true;
|
||||
}
|
||||
// 3) 선 위 · 꺾임점 = 갈래
|
||||
const hit = lineAt(network, px, py, map);
|
||||
if (hit) {
|
||||
let next = network;
|
||||
let vertex = hit.vertex;
|
||||
if (vertex === null) {
|
||||
next = insertVertex(next, hit.line, hit.segment, hit.point);
|
||||
vertex = hit.segment + 1;
|
||||
}
|
||||
params.commit(addBranch(next, hit.line, vertex));
|
||||
[active, flip] = [params.network().lines.length - 1, false];
|
||||
return true;
|
||||
}
|
||||
strokeStart = null;
|
||||
showToast("시점 · 종점 · 선 끝 · 선 위를 눌러 시작합니다.", "info");
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 긋는 해가 끝 조각 해와 다르면 `fromM`(이번 점 앞 길이)에서 나눠 새 조각 = 긋는 해. */
|
||||
function yearSplit(network: PlanNetwork, line: number, fromM: number): PlanNetwork {
|
||||
const year = params.year();
|
||||
if (!(fromM > 0)) return network;
|
||||
let next = normalizePieces(network, year);
|
||||
const id = next.lines[line].id;
|
||||
const tail = next.pieces.filter((piece) => piece.line === id).at(-1);
|
||||
if (!tail || tail.year === year) return next;
|
||||
next = splitPiece(next, id, fromM);
|
||||
const index = next.pieces.findIndex(
|
||||
(piece) => piece.line === id && Math.abs(piece.from_m - fromM) < 1e-3,
|
||||
);
|
||||
return index >= 0 ? setPieceYear(next, index, year) : next;
|
||||
}
|
||||
|
||||
function addPoint(): boolean {
|
||||
if (active === null || !preview) return false;
|
||||
if (preview.blocked) {
|
||||
@@ -334,9 +420,14 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
}
|
||||
const line = active;
|
||||
const ended = preview.snapped;
|
||||
params.commit(appendVertex(params.network(), line, preview.point));
|
||||
let next = params.network();
|
||||
const before = Math.round(lineLength(next.lines[line].vertices) * 1000) / 1000;
|
||||
if (preview.onLine)
|
||||
next = insertVertex(next, preview.onLine.line, preview.onLine.segment, preview.point);
|
||||
next = yearSplit(appendVertex(next, line, preview.point), line, before);
|
||||
params.commit(next);
|
||||
if (ended) {
|
||||
showToast("본선이 종점에 닿았습니다.", "success");
|
||||
showToast(`${preview.note ?? "붙음"} — 긋기 끝`, "success");
|
||||
setMode("idle");
|
||||
}
|
||||
return true;
|
||||
@@ -344,95 +435,44 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
|
||||
const layer: PlanMapLayer = {
|
||||
draw(context, map) {
|
||||
const colors = planColors();
|
||||
const { start, end } = params.base();
|
||||
const network = params.network();
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
network.lines.forEach((line, index) => {
|
||||
if (line.vertices.length < 2) return;
|
||||
context.strokeStyle = index === active ? colors.active : colors.line;
|
||||
context.lineWidth = index === 0 ? 3 : 2.5;
|
||||
context.beginPath();
|
||||
line.vertices.forEach((v, i) => {
|
||||
const [sx, sy] = map.toScreen(v);
|
||||
if (i) context.lineTo(sx, sy);
|
||||
else context.moveTo(sx, sy);
|
||||
});
|
||||
context.stroke();
|
||||
if (map.stations)
|
||||
drawStationTicks(
|
||||
paintNetwork(context, map, network, params.base(), active, params.heightAt(), () => {
|
||||
const from = active === null ? null : lastOf(active);
|
||||
if (from && preview)
|
||||
paintPreview(
|
||||
context,
|
||||
line.vertices.map(([x, y]) => ({ x, y })),
|
||||
{
|
||||
intervalM: PLAN_STATION_INTERVAL_M,
|
||||
pxPerMeter: map.pxPerMeter,
|
||||
toScreen: (x, y) => map.toScreen([x, y]),
|
||||
uprightRad: map.uprightRad,
|
||||
chainages: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M),
|
||||
},
|
||||
map,
|
||||
from,
|
||||
preview,
|
||||
kind === "both" ? { lengthM, candidates } : null,
|
||||
);
|
||||
if (map.slopes) segmentLabels(context, map, line.vertices);
|
||||
});
|
||||
// 꺾임점
|
||||
context.fillStyle = "#ffffff";
|
||||
for (const line of network.lines)
|
||||
for (const v of line.vertices.slice(1)) {
|
||||
const [sx, sy] = map.toScreen(v);
|
||||
context.beginPath();
|
||||
context.arc(sx, sy, 3, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
drawPieces(context, map, network);
|
||||
drawPreview(context, map, colors);
|
||||
// 시점(초록) · 종점(빨강)
|
||||
for (const [point, fill] of [
|
||||
[start, "#16a34a"],
|
||||
[end, "#dc2626"],
|
||||
] as const) {
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
context.beginPath();
|
||||
context.arc(sx, sy, 7, 0, Math.PI * 2);
|
||||
context.fillStyle = fill;
|
||||
context.fill();
|
||||
context.lineWidth = 2;
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.stroke();
|
||||
}
|
||||
},
|
||||
press(px, py, map, button) {
|
||||
if (!editable) return false;
|
||||
if (mode === "draw" && button === 0) {
|
||||
if (active === null) {
|
||||
const started = begin(px, py, map);
|
||||
panelState();
|
||||
params.onMode();
|
||||
return started;
|
||||
}
|
||||
preview = previewAt(px, py, map);
|
||||
return addPoint();
|
||||
}
|
||||
if (mode === "branch" && button === 0) {
|
||||
const hit = lineAt(px, py, map);
|
||||
if (!hit) return false;
|
||||
let network = params.network();
|
||||
let vertex = hit.vertex;
|
||||
if (vertex === null) {
|
||||
network = insertVertex(network, hit.line, hit.segment, hit.point);
|
||||
vertex = hit.segment + 1;
|
||||
}
|
||||
strokeStart = params.network();
|
||||
params.commit(addBranch(network, hit.line, vertex));
|
||||
mode = "draw";
|
||||
active = params.network().lines.length - 1;
|
||||
panelState();
|
||||
params.onMode();
|
||||
return true;
|
||||
}
|
||||
if (mode === "mark") {
|
||||
const hit = lineAt(px, py, map);
|
||||
if (!hit) return false;
|
||||
const network = params.network();
|
||||
const hit = lineAt(network, px, py, map);
|
||||
if (!hit) return false;
|
||||
const id = network.lines[hit.line].id;
|
||||
const next =
|
||||
button === 2
|
||||
? removeMark(network, id, hit.atM, SNAP_PX / map.pxPerMeter)
|
||||
: splitPiece(network, id, hit.atM);
|
||||
if (next !== network) params.commit(next);
|
||||
if (next === network) return true;
|
||||
const added = next.pieces.find((piece) => !network.pieces.some((p) => p.id === piece.id));
|
||||
params.commit(next);
|
||||
if (added) params.onMark(added.id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -445,120 +485,6 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
},
|
||||
};
|
||||
|
||||
function segmentLabels(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
vertices: Vertex[],
|
||||
): void {
|
||||
const z = params.heightAt();
|
||||
for (let i = 1; i < vertices.length; i += 1) {
|
||||
const [a, b] = [map.toScreen(vertices[i - 1]), map.toScreen(vertices[i])];
|
||||
if (Math.hypot(b[0] - a[0], b[1] - a[1]) < LABEL_MIN_PX) continue;
|
||||
const slope = groundSlopePct(vertices[i - 1], vertices[i], z);
|
||||
const text = `${fmt(segmentLength(vertices[i - 1], vertices[i]))} m · ${slope === null ? "—" : `${signed(slope)} %`}`;
|
||||
label(context, map, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 - 12, text, "11px");
|
||||
}
|
||||
}
|
||||
|
||||
function drawPieces(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
network: PlanNetwork,
|
||||
): void {
|
||||
const colors = planColors();
|
||||
network.pieces.forEach((piece) => {
|
||||
const vertices = network.lines.find((line) => line.id === piece.line)?.vertices;
|
||||
if (!vertices || vertices.length < 2) return;
|
||||
if (piece.from_m > 0) {
|
||||
const [sx, sy] = map.toScreen(pointAtDistance(vertices, piece.from_m));
|
||||
context.beginPath();
|
||||
context.moveTo(sx, sy - 8);
|
||||
context.lineTo(sx + 6, sy);
|
||||
context.lineTo(sx, sy + 8);
|
||||
context.lineTo(sx - 6, sy);
|
||||
context.closePath();
|
||||
context.fillStyle = colors.active;
|
||||
context.fill();
|
||||
context.lineWidth = 1.5;
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.stroke();
|
||||
}
|
||||
const [mx, my] = map.toScreen(pointAtDistance(vertices, (piece.from_m + piece.to_m) / 2));
|
||||
label(context, map, mx, my + 16, `${piece.year}년`, "bold 12px");
|
||||
});
|
||||
}
|
||||
|
||||
function drawPreview(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
colors: ReturnType<typeof planColors>,
|
||||
): void {
|
||||
if (active === null || !preview) return;
|
||||
const from = lastOf(active);
|
||||
if (!from) return;
|
||||
const [ax, ay] = map.toScreen(from);
|
||||
const [bx, by] = map.toScreen(preview.point);
|
||||
if (kind === "both") {
|
||||
context.strokeStyle = colors.active;
|
||||
context.lineWidth = 1;
|
||||
context.setLineDash([3, 4]);
|
||||
context.beginPath();
|
||||
context.arc(ax, ay, lengthM * map.pxPerMeter, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
context.fillStyle = colors.active;
|
||||
for (const c of candidates) {
|
||||
const [cx, cy] = map.toScreen(c);
|
||||
context.beginPath();
|
||||
context.arc(cx, cy, 3, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
}
|
||||
context.strokeStyle = preview.blocked ? colors.warn : colors.active;
|
||||
context.lineWidth = 2;
|
||||
context.setLineDash([6, 4]);
|
||||
context.beginPath();
|
||||
context.moveTo(ax, ay);
|
||||
context.lineTo(bx, by);
|
||||
context.stroke();
|
||||
context.setLineDash([]);
|
||||
context.beginPath();
|
||||
context.arc(bx, by, 5, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
const value =
|
||||
`${fmt(preview.lengthM)} m · ` +
|
||||
(preview.slopePct === null ? "지형 밖" : `${signed(preview.slopePct)} %`);
|
||||
label(
|
||||
context,
|
||||
map,
|
||||
bx + 14,
|
||||
by - 14,
|
||||
preview.note ? `${value} · ${preview.note}` : value,
|
||||
"12px",
|
||||
"left",
|
||||
);
|
||||
}
|
||||
|
||||
function label(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
font: string,
|
||||
align: CanvasTextAlign = "center",
|
||||
): void {
|
||||
map.upright(context, x, y, () => {
|
||||
context.font = `${font} system-ui, sans-serif`;
|
||||
context.textAlign = align;
|
||||
context.textBaseline = "middle";
|
||||
context.lineWidth = 3;
|
||||
context.strokeStyle = planColors().halo;
|
||||
context.strokeText(text, x, y);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillText(text, x, y);
|
||||
});
|
||||
}
|
||||
|
||||
panelState();
|
||||
|
||||
return {
|
||||
@@ -579,19 +505,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
if (mode !== "idle" && event.key === "Escape") {
|
||||
if (mode === "draw" && strokeStart && strokeStart !== params.network())
|
||||
params.commit(strokeStart);
|
||||
active = null; // 되돌린 꼴이라 stop 의 갈래 버림은 안 함
|
||||
active = null; // 되돌린 꼴이라 stop 의 버림 · 뒤집기는 안 함
|
||||
setMode("idle");
|
||||
return true;
|
||||
}
|
||||
if (mode === "draw" && active !== null && event.key === "Backspace") {
|
||||
const network = params.network();
|
||||
const step =
|
||||
active > 0 && network.lines[active].vertices.length < 2
|
||||
? dropLastLine(network)
|
||||
network.lines[active].vertices.length < 2
|
||||
? removeLine(network, active)
|
||||
: dropLastVertex(network, active);
|
||||
if (typeof step === "string") showToast(step, "info");
|
||||
else if (step !== network) params.commit(step);
|
||||
if (active >= params.network().lines.length) setMode("idle");
|
||||
if (active >= params.network().lines.length) {
|
||||
active = null;
|
||||
setMode("idle");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -599,17 +528,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
sync() {
|
||||
if (active !== null && active >= params.network().lines.length) {
|
||||
active = null;
|
||||
flip = false;
|
||||
mode = "idle";
|
||||
}
|
||||
panelState();
|
||||
},
|
||||
preview: () => preview,
|
||||
stroke: () => (active === null ? null : { line: active, flip }),
|
||||
constraint: () => ({ kind, lengthM, slopePct }),
|
||||
setConstraint(next, length, slope) {
|
||||
kind = next;
|
||||
kindSegment.set(next);
|
||||
if (length !== undefined) [lengthM, lengthField.input.value] = [length, String(length)];
|
||||
if (slope !== undefined) [slopePct, slopeField.input.value] = [slope, String(slope)];
|
||||
if (slope !== undefined) {
|
||||
[slopePct, slopeField.input.value] = [slope, String(slope)];
|
||||
degText();
|
||||
}
|
||||
fieldsState();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_DrawPaint.ts
|
||||
* D03 손 긋기 겹 그림 · 집기(PLAN 562-3 · 580) — `D03_PlanAnalysis_UI_Draw`(긋기) ·
|
||||
* `D03_PlanAnalysis_UI_Edit`(고르기 · 고치기)가 같이 씀. 그림 = 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 ·
|
||||
* 시점 · 종점 · 미리보기(거리 · 가상 종단 기울기 % · °) · 집기 = 화면 px 둘레 선 위 자리.
|
||||
* ========================================================================== */
|
||||
|
||||
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
||||
import {
|
||||
distanceAlong,
|
||||
groundSlopePct,
|
||||
lineLength,
|
||||
pointAtDistance,
|
||||
segmentLength,
|
||||
stationChainages,
|
||||
type HeightAt,
|
||||
} from "./logic/D03_PlanAnalysis_Draw";
|
||||
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import { planColors, PLAN_STATION_INTERVAL_M, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||||
import type { DrawPreview } from "./D03_PlanAnalysis_UI_Draw";
|
||||
|
||||
/** 시점 · 종점 · 꺾임점에 붙는 반경(px). */
|
||||
export const SNAP_PX = 12;
|
||||
/** 선 위 누름으로 볼 반경(px). */
|
||||
export const LINE_HIT_PX = 8;
|
||||
/** 구간 길이 라벨을 보일 화면 길이(px). */
|
||||
const LABEL_MIN_PX = 70;
|
||||
|
||||
export const fmt = (value: number): string => value.toFixed(1);
|
||||
const signed = (value: number): string => `${value >= 0 ? "+" : ""}${value.toFixed(1)}`;
|
||||
/** 기울기 % → 각(°) — 가상 종단 기울기를 두 단위로. */
|
||||
export const slopeDeg = (pct: number): number => (Math.atan(pct / 100) * 180) / Math.PI;
|
||||
const slopeText = (pct: number): string => `${signed(pct)} % (${signed(slopeDeg(pct))}°)`;
|
||||
/** 화면 px 거리(작업 좌표계 점 ↔ 마우스). */
|
||||
export const screenGap = (map: ScreenMap, point: Vertex, px: number, py: number): number => {
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
return Math.hypot(sx - px, sy - py);
|
||||
};
|
||||
|
||||
/** 화면 px 둘레 선 위 자리 — 가장 가까운 선 · 누가 · 꺾임점(가까우면) · 구간 · `skip` 선 빼고. */
|
||||
export function lineAt(
|
||||
network: PlanNetwork,
|
||||
px: number,
|
||||
py: number,
|
||||
map: ScreenMap,
|
||||
skip: number | null = null,
|
||||
): { line: number; atM: number; point: Vertex; vertex: number | null; segment: number } | null {
|
||||
const cursor = map.toMetric(px, py);
|
||||
let best: ReturnType<typeof lineAt> = null;
|
||||
let bestPx = LINE_HIT_PX;
|
||||
network.lines.forEach((line, index) => {
|
||||
if (line.vertices.length < 2 || index === skip) return;
|
||||
// 붙는 반경 안 꺾임점 중 가장 가까운 것
|
||||
let vertex = -1;
|
||||
let vertexPx = SNAP_PX;
|
||||
line.vertices.forEach((v, i) => {
|
||||
const [vx, vy] = map.toScreen(v);
|
||||
const d = Math.hypot(vx - px, vy - py);
|
||||
if (d <= vertexPx) [vertex, vertexPx] = [i, d];
|
||||
});
|
||||
const atM =
|
||||
vertex >= 0
|
||||
? lineLength(line.vertices.slice(0, vertex + 1))
|
||||
: distanceAlong(line.vertices, cursor);
|
||||
const point = vertex >= 0 ? line.vertices[vertex] : pointAtDistance(line.vertices, atM);
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
const gap = vertex >= 0 ? 0 : Math.hypot(sx - px, sy - py);
|
||||
if (gap > bestPx || (best && gap === bestPx)) return;
|
||||
// 누가가 든 구간(꺾임점 끼울 자리)
|
||||
let segment = 0;
|
||||
for (let walked = 0; segment < line.vertices.length - 2; segment += 1) {
|
||||
walked += segmentLength(line.vertices[segment], line.vertices[segment + 1]);
|
||||
if (walked >= atM) break;
|
||||
}
|
||||
best = { line: index, atM, point, vertex: vertex >= 0 ? vertex : null, segment };
|
||||
bestPx = gap;
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 그은 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · (extra = 미리보기) · 시점(초록) · 종점(빨강).
|
||||
* `active` = 그리는 선(굵은 보라). */
|
||||
export function paintNetwork(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
network: PlanNetwork,
|
||||
base: { starts: Vertex[]; ends: Vertex[] },
|
||||
active: number | null,
|
||||
z: HeightAt,
|
||||
extra: () => void,
|
||||
): void {
|
||||
const { starts, ends } = base;
|
||||
const colors = planColors();
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
network.lines.forEach((line, index) => {
|
||||
if (line.vertices.length < 2) return;
|
||||
context.strokeStyle = index === active ? colors.active : colors.line;
|
||||
context.lineWidth = line.branch ? 2.5 : 3;
|
||||
context.beginPath();
|
||||
line.vertices.forEach((v, i) => {
|
||||
const [sx, sy] = map.toScreen(v);
|
||||
if (i) context.lineTo(sx, sy);
|
||||
else context.moveTo(sx, sy);
|
||||
});
|
||||
context.stroke();
|
||||
if (map.stations)
|
||||
drawStationTicks(
|
||||
context,
|
||||
line.vertices.map(([x, y]) => ({ x, y })),
|
||||
{
|
||||
intervalM: PLAN_STATION_INTERVAL_M,
|
||||
pxPerMeter: map.pxPerMeter,
|
||||
toScreen: (x, y) => map.toScreen([x, y]),
|
||||
uprightRad: map.uprightRad,
|
||||
chainages: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M),
|
||||
},
|
||||
);
|
||||
if (map.slopes) segmentLabels(context, map, line.vertices, z);
|
||||
});
|
||||
// 꺾임점
|
||||
context.fillStyle = "#ffffff";
|
||||
for (const line of network.lines)
|
||||
for (const v of line.vertices.slice(1)) {
|
||||
const [sx, sy] = map.toScreen(v);
|
||||
context.beginPath();
|
||||
context.arc(sx, sy, 3, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
drawPieces(context, map, network);
|
||||
extra();
|
||||
// 시점(초록) · 종점(빨강)
|
||||
for (const [points, fill] of [
|
||||
[starts, "#16a34a"],
|
||||
[ends, "#dc2626"],
|
||||
] as const)
|
||||
for (const point of points) {
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
context.beginPath();
|
||||
context.arc(sx, sy, 7, 0, Math.PI * 2);
|
||||
context.fillStyle = fill;
|
||||
context.fill();
|
||||
context.lineWidth = 2;
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function segmentLabels(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
vertices: Vertex[],
|
||||
z: HeightAt,
|
||||
): void {
|
||||
for (let i = 1; i < vertices.length; i += 1) {
|
||||
const [a, b] = [map.toScreen(vertices[i - 1]), map.toScreen(vertices[i])];
|
||||
if (Math.hypot(b[0] - a[0], b[1] - a[1]) < LABEL_MIN_PX) continue;
|
||||
const slope = groundSlopePct(vertices[i - 1], vertices[i], z);
|
||||
const text = `${fmt(segmentLength(vertices[i - 1], vertices[i]))} m · ${slope === null ? "—" : `${signed(slope)} %`}`;
|
||||
planLabel(context, map, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 - 12, text, "11px");
|
||||
}
|
||||
}
|
||||
|
||||
function drawPieces(context: CanvasRenderingContext2D, map: ScreenMap, network: PlanNetwork): void {
|
||||
const colors = planColors();
|
||||
network.pieces.forEach((piece) => {
|
||||
const vertices = network.lines.find((line) => line.id === piece.line)?.vertices;
|
||||
if (!vertices || vertices.length < 2) return;
|
||||
if (piece.from_m > 0) {
|
||||
const [sx, sy] = map.toScreen(pointAtDistance(vertices, piece.from_m));
|
||||
context.beginPath();
|
||||
context.moveTo(sx, sy - 8);
|
||||
context.lineTo(sx + 6, sy);
|
||||
context.lineTo(sx, sy + 8);
|
||||
context.lineTo(sx - 6, sy);
|
||||
context.closePath();
|
||||
context.fillStyle = colors.active;
|
||||
context.fill();
|
||||
context.lineWidth = 1.5;
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.stroke();
|
||||
}
|
||||
const [mx, my] = map.toScreen(pointAtDistance(vertices, (piece.from_m + piece.to_m) / 2));
|
||||
planLabel(context, map, mx, my + 16, `${piece.year}년`, "bold 12px");
|
||||
});
|
||||
}
|
||||
|
||||
/** 미리보기 — 점선 · 둘 다(양각기) 원 · 후보 · 글(거리 · 가상 종단 기울기 % · °). */
|
||||
export function paintPreview(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
from: Vertex,
|
||||
preview: DrawPreview,
|
||||
compass: { lengthM: number; candidates: Vertex[] } | null,
|
||||
): void {
|
||||
const colors = planColors();
|
||||
const [ax, ay] = map.toScreen(from);
|
||||
const [bx, by] = map.toScreen(preview.point);
|
||||
if (compass) {
|
||||
context.strokeStyle = colors.active;
|
||||
context.lineWidth = 1;
|
||||
context.setLineDash([3, 4]);
|
||||
context.beginPath();
|
||||
context.arc(ax, ay, compass.lengthM * map.pxPerMeter, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
context.fillStyle = colors.active;
|
||||
for (const c of compass.candidates) {
|
||||
const [cx, cy] = map.toScreen(c);
|
||||
context.beginPath();
|
||||
context.arc(cx, cy, 3, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
}
|
||||
context.strokeStyle = preview.blocked ? colors.warn : colors.active;
|
||||
context.lineWidth = 2;
|
||||
context.setLineDash([6, 4]);
|
||||
context.beginPath();
|
||||
context.moveTo(ax, ay);
|
||||
context.lineTo(bx, by);
|
||||
context.stroke();
|
||||
context.setLineDash([]);
|
||||
context.beginPath();
|
||||
context.arc(bx, by, 5, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
const value =
|
||||
`${fmt(preview.lengthM)} m · ` +
|
||||
(preview.slopePct === null ? "지형 밖" : slopeText(preview.slopePct));
|
||||
planLabel(
|
||||
context,
|
||||
map,
|
||||
bx + 14,
|
||||
by - 14,
|
||||
preview.note ? `${value} · ${preview.note}` : value,
|
||||
"12px",
|
||||
"left",
|
||||
);
|
||||
}
|
||||
|
||||
export function planLabel(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
font: string,
|
||||
align: CanvasTextAlign = "center",
|
||||
): void {
|
||||
map.upright(context, x, y, () => {
|
||||
context.font = `${font} system-ui, sans-serif`;
|
||||
context.textAlign = align;
|
||||
context.textBaseline = "middle";
|
||||
context.lineWidth = 3;
|
||||
context.strokeStyle = planColors().halo;
|
||||
context.strokeText(text, x, y);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillText(text, x, y);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Edit.ts
|
||||
* D03 고르기 · 고치기 겹(PLAN 580 · 요구서 440 3 · 6 · 7) — 쉴 때(긋기 · 연도 구분 · 재기 아님)만.
|
||||
*
|
||||
* 선 누름 = 그 선 · 조각 고름(굵게 · 좌측 「구역 · 연도」 그 줄 밝힘) · 빈 곳 = 고름 풂.
|
||||
* 고른 선 위 누름 = 꺾임점 더함(그대로 끌면 옮김) · 꺾임점 끌기 = 옮김(붙은 갈래 첫 점 같이) ·
|
||||
* 마크 끌기 = 연도 구분 옮김 · 오른쪽 누름 = 꺾임점 · 마크 지움 · Delete = 고른 선 지움.
|
||||
* 잠금 = 시점 · 종점에 놓인 점(1단계 몫) · 갈래 첫 점(붙은 선 점을 끌면 같이 감).
|
||||
* 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음.
|
||||
* 좌측 「고른 선」 칸 = 이름 · 길이 · [방향 바꾸기](측점 No.0 쪽 바꿈) · [선 지움].
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import {
|
||||
distanceAlong,
|
||||
lineLength,
|
||||
pointAtDistance,
|
||||
sliceLine,
|
||||
} from "./logic/D03_PlanAnalysis_Draw";
|
||||
import {
|
||||
insertVertex,
|
||||
lineNames,
|
||||
moveMark,
|
||||
moveVertex,
|
||||
removeLine,
|
||||
removeMark,
|
||||
removeVertex,
|
||||
reverseLine,
|
||||
samePoint,
|
||||
type PlanNetwork,
|
||||
type Vertex,
|
||||
} 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";
|
||||
|
||||
export interface PlanEditParams {
|
||||
network: () => PlanNetwork;
|
||||
base: () => { starts: Vertex[]; ends: Vertex[] };
|
||||
commit: (next: PlanNetwork) => void;
|
||||
/** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */
|
||||
show: (next: PlanNetwork) => void;
|
||||
/** 고르기 받는 때(쉴 때 · 재기 아님). */
|
||||
enabled: () => boolean;
|
||||
/** 고치기 됨(산림청). */
|
||||
editable: () => boolean;
|
||||
/** 고름이 바뀜 — 페이지가 좌측 줄 밝힘. */
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
export interface PlanSelection {
|
||||
line: string;
|
||||
piece: string | null;
|
||||
}
|
||||
|
||||
export interface PlanEdit {
|
||||
layer: PlanMapLayer;
|
||||
/** 좌측 「고른 선」 칸. */
|
||||
panel: HTMLElement;
|
||||
selected: () => PlanSelection | null;
|
||||
select: (line: string | null, piece?: string | null) => void;
|
||||
/** 키 — 받았으면 참(Delete · Esc). */
|
||||
key: (event: KeyboardEvent) => boolean;
|
||||
/** 되돌리기 · 저장 뒤 — 고른 선이 사라졌으면 풂 · 칸 다시. */
|
||||
sync: () => void;
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
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 indexOf = (network: PlanNetwork): number =>
|
||||
selected ? network.lines.findIndex((line) => line.id === selected!.line) : -1;
|
||||
|
||||
/** 고른 선에 꼴 바꿈 하나 — 까닭 글이면 알림. */
|
||||
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 next = step(network, index);
|
||||
if (typeof next === "string") return void showToast(next, "info");
|
||||
params.commit(next);
|
||||
sync();
|
||||
}
|
||||
|
||||
function panelState(): 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;
|
||||
}
|
||||
|
||||
function select(line: string | null, piece: string | null = null): void {
|
||||
selected = line ? { line, piece } : null;
|
||||
panelState();
|
||||
params.onSelect();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
const pieceAt = (network: PlanNetwork, line: string, atM: number): string | null =>
|
||||
network.pieces.find((p) => p.line === line && atM >= p.from_m - 1e-6 && atM <= p.to_m + 1e-6)
|
||||
?.id ?? null;
|
||||
|
||||
/** 화면 px 둘레 마크(조각 시작) — 조각 차례. */
|
||||
function markAt(network: PlanNetwork, px: number, py: number, map: ScreenMap): number | null {
|
||||
let best: number | null = null;
|
||||
let bestPx = SNAP_PX;
|
||||
network.pieces.forEach((piece, index) => {
|
||||
const vertices = network.lines.find((line) => line.id === piece.line)?.vertices;
|
||||
if (!vertices || !(piece.from_m > 0)) return;
|
||||
const gap = screenGap(map, pointAtDistance(vertices, piece.from_m), px, py);
|
||||
if (gap <= bestPx) [best, bestPx] = [index, gap];
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 화면 px 둘레 꺾임점 — 갈래 첫 점은 붙은 선 점이 대신 집힘. */
|
||||
function vertexAt(
|
||||
network: PlanNetwork,
|
||||
px: number,
|
||||
py: number,
|
||||
map: ScreenMap,
|
||||
): { line: number; vertex: number } | null {
|
||||
let best: { line: number; vertex: number } | null = null;
|
||||
let bestPx = SNAP_PX;
|
||||
network.lines.forEach((line, i) =>
|
||||
line.vertices.forEach((point, v) => {
|
||||
if (v === 0 && line.branch) return;
|
||||
const gap = screenGap(map, point, px, py);
|
||||
if (gap <= bestPx) [best, bestPx] = [{ line: i, vertex: v }, gap];
|
||||
}),
|
||||
);
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 잠금 까닭 — 시점 · 종점에 놓인 점. */
|
||||
function lockOf(network: PlanNetwork, line: number, vertex: number): string | null {
|
||||
const point = network.lines[line].vertices[vertex];
|
||||
const { starts, ends } = params.base();
|
||||
return [...starts, ...ends].some((p) => samePoint(p, point))
|
||||
? "시점 · 종점 자리 점은 1단계(기준 지정)에서 옮깁니다."
|
||||
: null;
|
||||
}
|
||||
|
||||
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) => {
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
if (i) context.lineTo(sx, sy);
|
||||
else context.moveTo(sx, sy);
|
||||
});
|
||||
context.stroke();
|
||||
};
|
||||
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 = 1;
|
||||
// 꺾임점 손잡이
|
||||
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);
|
||||
}
|
||||
},
|
||||
press(px, py, map, button) {
|
||||
if (!params.enabled()) return false;
|
||||
const network = params.network();
|
||||
const editable = params.editable();
|
||||
const mark = markAt(network, px, py, map);
|
||||
const vertex = vertexAt(network, px, py, map);
|
||||
if (button === 2) {
|
||||
if (!editable) return false;
|
||||
if (mark !== null) {
|
||||
const piece = network.pieces[mark];
|
||||
params.commit(removeMark(network, piece.line, piece.from_m, 1e-3));
|
||||
sync();
|
||||
return true;
|
||||
}
|
||||
if (!vertex) return false;
|
||||
const next = removeVertex(network, vertex.line, vertex.vertex);
|
||||
if (typeof next === "string") showToast(next, "info");
|
||||
else params.commit(next);
|
||||
sync();
|
||||
return true;
|
||||
}
|
||||
if (mark !== null) {
|
||||
const piece = network.pieces[mark];
|
||||
select(piece.line, 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 lock = lockOf(network, vertex.line, vertex.vertex);
|
||||
if (editable)
|
||||
drag = lock
|
||||
? { kind: "locked", note: lock, told: false }
|
||||
: { kind: "vertex", ...vertex, before: network, last: null };
|
||||
return true;
|
||||
}
|
||||
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));
|
||||
return true;
|
||||
}
|
||||
if (!selected) return false;
|
||||
select(null);
|
||||
return true;
|
||||
},
|
||||
hover(px, py, map) {
|
||||
if (!drag) return;
|
||||
if (drag.kind === "locked") {
|
||||
if (!drag.told) showToast(drag.note, "info");
|
||||
drag.told = true;
|
||||
return;
|
||||
}
|
||||
const cursor = map.toMetric(px, py);
|
||||
if (drag.kind === "vertex") {
|
||||
drag.last = moveVertex(drag.before, drag.line, drag.vertex, cursor);
|
||||
} else {
|
||||
const piece = drag.before.pieces[drag.index];
|
||||
const vertices = drag.before.lines.find((line) => line.id === piece.line)!.vertices;
|
||||
drag.last = moveMark(drag.before, drag.index, distanceAlong(vertices, cursor));
|
||||
}
|
||||
params.show(drag.last);
|
||||
},
|
||||
release() {
|
||||
const done = drag;
|
||||
drag = null;
|
||||
if (!done || done.kind === "locked" || !done.last) return;
|
||||
params.show(done.before);
|
||||
params.commit(done.last);
|
||||
sync();
|
||||
},
|
||||
};
|
||||
|
||||
panelState();
|
||||
|
||||
return {
|
||||
layer,
|
||||
panel,
|
||||
selected: () => selected,
|
||||
select,
|
||||
key(event) {
|
||||
if (!selected || !params.enabled()) return false;
|
||||
if (event.key === "Delete" && params.editable()) {
|
||||
apply((network, index) => removeLine(network, index));
|
||||
return true;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
select(null);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
sync,
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
|
||||
* 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음 · 받기 실패 = 그 겹만 꺼짐 + 알림.
|
||||
* 그리기 차례(아래 → 위) = 위성(`scene.image`) → 음영기복 → 급경사 → 보호구역 → 띠 → 지적 → 물 면 →
|
||||
* 건물 → 등고선 → 도로 → 임도 짐작 → 세류 → 지명 → 그은 선(모두 `layers` = 바탕 겹 · 582 두 장이면 아래 장).
|
||||
* 건물 → 등고선 → 도로 → 임도 짐작 → 세류 → 지명 → 그은 선.
|
||||
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
|
||||
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -34,7 +35,10 @@ import {
|
||||
type GeoJsonCollection,
|
||||
type PreparedLayer,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
|
||||
import {
|
||||
indexLayerLod,
|
||||
prepareLayer,
|
||||
} from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
|
||||
import {
|
||||
fetchPlanBands,
|
||||
fetchPlanCadastral,
|
||||
@@ -240,7 +244,7 @@ export function createPlanLayers(opts: {
|
||||
void ensure(key);
|
||||
showSatellite();
|
||||
sync();
|
||||
map.redraw();
|
||||
map.redrawBase();
|
||||
}
|
||||
|
||||
function showSatellite(): void {
|
||||
@@ -259,7 +263,7 @@ export function createPlanLayers(opts: {
|
||||
if (meta !== at) return;
|
||||
showSatellite();
|
||||
sync();
|
||||
map.redraw();
|
||||
map.redrawBase();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const text = error instanceof Error ? error.message : String(error);
|
||||
@@ -306,7 +310,7 @@ export function createPlanLayers(opts: {
|
||||
"route",
|
||||
bbox.join(","),
|
||||
);
|
||||
data.contour = prepare(collection, ["등고수치"]);
|
||||
data.contour = indexLayerLod(prepare(collection, ["등고수치"]));
|
||||
} else if (key === "stream") {
|
||||
const collection = await fetchCachedSheetLayer<GeoJsonCollection>(
|
||||
projectId,
|
||||
@@ -391,8 +395,11 @@ export function createPlanLayers(opts: {
|
||||
context.drawImage(raster.image, left, top, right - left, bottom - top);
|
||||
};
|
||||
|
||||
/** 등고 글 폭 기억(글꼴 하나 · 582). */
|
||||
const labelWidths = new Map<string, number>();
|
||||
const layers: PlanMapLayer[] = [
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
if (!on.hillshade || !data.hillshade) return;
|
||||
// 위성 위 = 곱하기(밝은 곳은 그대로 · 그늘만 짙게) · 위성 없으면 그대로
|
||||
@@ -404,6 +411,7 @@ export function createPlanLayers(opts: {
|
||||
},
|
||||
},
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
if (!on.slope || !data.slope) return;
|
||||
context.globalAlpha = 0.55;
|
||||
@@ -411,6 +419,7 @@ export function createPlanLayers(opts: {
|
||||
},
|
||||
},
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
if (!on.zones || !data.zones) return;
|
||||
for (const [kind, layer] of data.zones.kinds) {
|
||||
@@ -420,6 +429,7 @@ export function createPlanLayers(opts: {
|
||||
},
|
||||
},
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
if (!on.bands || !data.bands) return;
|
||||
const road = themeColor("--draw-map-band-road", "#facc15");
|
||||
@@ -437,6 +447,7 @@ export function createPlanLayers(opts: {
|
||||
},
|
||||
},
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
if (!on.cadastral || !data.cadastral) return;
|
||||
context.globalAlpha = 0.8;
|
||||
@@ -444,6 +455,7 @@ export function createPlanLayers(opts: {
|
||||
},
|
||||
},
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
if (!on.water || !data.water) return;
|
||||
const color = themeColor("--draw-map-water", "#0ea5e9");
|
||||
@@ -451,6 +463,7 @@ export function createPlanLayers(opts: {
|
||||
},
|
||||
},
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
if (!on.building || !data.building) return;
|
||||
const color = themeColor("--draw-map-building", "#fb7185");
|
||||
@@ -461,6 +474,7 @@ export function createPlanLayers(opts: {
|
||||
},
|
||||
},
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
const contour = on.contour && data.contour;
|
||||
if (!contour) return;
|
||||
@@ -480,10 +494,12 @@ export function createPlanLayers(opts: {
|
||||
25,
|
||||
map.uprightRad,
|
||||
"rgba(0, 0, 0, 0.6)",
|
||||
labelWidths,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
if (!on.road || !data.road) return;
|
||||
stroke(context, map, data.road.all, "rgba(0, 0, 0, 0.55)", 3.2);
|
||||
@@ -491,6 +507,7 @@ export function createPlanLayers(opts: {
|
||||
},
|
||||
},
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
if (!on.forestRoad || !data.road) return;
|
||||
context.setLineDash([7, 4]);
|
||||
@@ -500,6 +517,7 @@ export function createPlanLayers(opts: {
|
||||
},
|
||||
},
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
if (!on.stream || !data.stream) return;
|
||||
const color = themeColor("--draw-map-stream", "#38bdf8");
|
||||
@@ -508,6 +526,7 @@ export function createPlanLayers(opts: {
|
||||
},
|
||||
},
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
if (!on.place || !data.place || !meta) return;
|
||||
const normalizer = createNormalizer(meta);
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* 회전 `createMapRotation`(B05 그대로 · 단추 `[data-act="rotate-ccw|cw"]` · Ctrl+휠).
|
||||
* 왼쪽 단추 = 겹 몫(점 찍기 · 마크 · 재기) — 왼쪽 끌기 옮기기 없음(설정 창 규칙 = 가운데 단추 옮김 · 휠 방향).
|
||||
* 겹은 돌리기 전 그림 좌표로 그리고 받음 — 누른 자리는 `unrotate` 로 되돌려 넘김.
|
||||
* 캔버스 두 장(582) — 아래 = 위성 · 바탕 겹(`base`) · 위 = 그은 선 · 재기 · 누름 받음.
|
||||
* 마우스 움직임 = 위만 다시 그림 · 화면이 바뀌면(옮김 · 확대 · 돌림 · 겹) 둘 다 · 한 화면 한 번(rAF).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools";
|
||||
@@ -64,11 +66,14 @@ export interface ScreenMap {
|
||||
upright: (context: CanvasRenderingContext2D, x: number, y: number, paint: () => void) => void;
|
||||
}
|
||||
|
||||
/** 평면 위 겹 — 그리기 · 누름(참 = 받음 · 0 왼쪽 · 2 오른쪽) · 마우스 곁 · 나감. */
|
||||
/** 평면 위 겹 — 그리기 · 누름(참 = 받음 · 0 왼쪽 · 2 오른쪽) · 마우스 곁(왼쪽 끌기 중도) · 놓음 · 나감. */
|
||||
export interface PlanMapLayer {
|
||||
/** 바탕 겹(아래 캔버스 · 마우스 움직임엔 안 다시 그림). */
|
||||
base?: boolean;
|
||||
draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void;
|
||||
press?: (px: number, py: number, map: ScreenMap, button: 0 | 2) => boolean;
|
||||
hover?: (px: number, py: number, map: ScreenMap) => void;
|
||||
release?: () => void;
|
||||
leave?: () => void;
|
||||
}
|
||||
|
||||
@@ -92,8 +97,10 @@ export interface PlanMap {
|
||||
update: (patch: Partial<PlanMapScene>) => void;
|
||||
setLayers: (layers: PlanMapLayer[]) => void;
|
||||
setTools: (tools: Partial<PlanTools>) => void;
|
||||
/** 다시 그림(겹 값이 바뀜). */
|
||||
/** 위 겹 다시 그림(그은 선 · 재기 값이 바뀜). */
|
||||
redraw: () => void;
|
||||
/** 바탕 겹까지 다시 그림(겹 켬끔 · 받음). */
|
||||
redrawBase: () => void;
|
||||
/** 맞출 점이 들어오게 다시 맞춤. */
|
||||
fit: () => void;
|
||||
/** 검증 창구 — 작업 좌표계 점 → 화면에 보이는 px(돌린 뒤 · 그리기 전이면 null). */
|
||||
@@ -130,6 +137,8 @@ function fakeMeta(points: Vertex[]): VWorldMeta | null {
|
||||
export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
const root = document.createElement("div");
|
||||
root.className = "d03-plan__map";
|
||||
const baseCanvas = document.createElement("canvas");
|
||||
baseCanvas.className = "d03-plan__base";
|
||||
const canvas = document.createElement("canvas");
|
||||
let marks = { stations: true, slopes: true };
|
||||
const markButton = (act: "stations" | "slopes", title: string): HTMLButtonElement => {
|
||||
@@ -139,7 +148,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
onClick: () => {
|
||||
marks = { ...marks, [act]: !marks[act] };
|
||||
button.setAttribute("aria-pressed", String(marks[act]));
|
||||
draw();
|
||||
drawTop();
|
||||
},
|
||||
});
|
||||
return button;
|
||||
@@ -152,11 +161,15 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
label: "평면 도구",
|
||||
hint: {
|
||||
lines: [
|
||||
"[긋기] 뒤 시점 · 종점 · 선 끝 · 선 위 누름 = 시작",
|
||||
"누름 = 점 찍기",
|
||||
"Enter = 긋기 끝",
|
||||
"Esc = 긋기 취소",
|
||||
"Backspace = 마지막 점 빼기",
|
||||
"오른쪽 누름 = 마크 지움",
|
||||
"선 누름 = 고름 · 고른 선 위 누름 = 꺾임점 더함",
|
||||
"꺾임점 · 마크 끌기 = 옮김",
|
||||
"오른쪽 누름 = 꺾임점 · 마크 지움",
|
||||
"Delete = 고른 선 지움",
|
||||
"가운데 끌기 = 옮기기",
|
||||
"휠 = 확대 · 축소",
|
||||
"Ctrl+휠 = 돌리기",
|
||||
@@ -170,7 +183,8 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
});
|
||||
const toolbar = tools.root;
|
||||
toolbar.classList.add("d03-plan__tools");
|
||||
root.append(canvas, toolbar);
|
||||
root.append(baseCanvas, canvas, toolbar);
|
||||
const baseContext = baseCanvas.getContext("2d")!;
|
||||
const context = canvas.getContext("2d")!;
|
||||
let scene: PlanMapScene = { meta: null, image: null, fit: [] };
|
||||
let layers: PlanMapLayer[] = [];
|
||||
@@ -231,33 +245,58 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
fitted = true;
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
context.clearRect(0, 0, view.width, view.height);
|
||||
/** 한 장 그림 — 아래 = 위성 + 바탕 겹 · 위 = 나머지 겹. */
|
||||
function paint(ctx: CanvasRenderingContext2D, base: boolean): void {
|
||||
ctx.clearRect(0, 0, view.width, view.height);
|
||||
if (!meta) return;
|
||||
context.save();
|
||||
context.translate(view.width / 2, view.height / 2);
|
||||
context.rotate(rotation.radians());
|
||||
context.translate(-view.width / 2, -view.height / 2);
|
||||
if (scene.image?.complete && scene.image.naturalWidth && scene.meta) {
|
||||
ctx.save();
|
||||
ctx.translate(view.width / 2, view.height / 2);
|
||||
ctx.rotate(rotation.radians());
|
||||
ctx.translate(-view.width / 2, -view.height / 2);
|
||||
if (base && scene.image?.complete && scene.image.naturalWidth && scene.meta) {
|
||||
const [x0, y0] = normalizedToScreen(view, 0, 0);
|
||||
const [x1, y1] = normalizedToScreen(view, 1, 1);
|
||||
context.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0);
|
||||
ctx.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0);
|
||||
}
|
||||
const map = screenMap();
|
||||
for (const layer of layers) {
|
||||
context.save();
|
||||
layer.draw(context, map);
|
||||
context.restore();
|
||||
if (!layer.base !== !base) continue;
|
||||
ctx.save();
|
||||
layer.draw(ctx, map);
|
||||
ctx.restore();
|
||||
}
|
||||
context.restore();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
let baseDirty = false;
|
||||
let topDirty = false;
|
||||
/** 모인 다시 그림을 지금 그림. */
|
||||
function flush(): void {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
if (baseDirty) paint(baseContext, true);
|
||||
if (topDirty) paint(context, false);
|
||||
baseDirty = topDirty = false;
|
||||
}
|
||||
/** 다음 화면에 한 번 — 바탕까지(화면 · 겹이 바뀜) 또는 위만(마우스). */
|
||||
function schedule(base: boolean): void {
|
||||
baseDirty ||= base;
|
||||
topDirty = true;
|
||||
if (!frame) frame = requestAnimationFrame(flush);
|
||||
}
|
||||
const draw = (): void => schedule(true);
|
||||
const drawTop = (): void => schedule(false);
|
||||
|
||||
function resize(): void {
|
||||
fitCanvas(baseCanvas, baseContext);
|
||||
const { width, height } = fitCanvas(canvas, context);
|
||||
if (!width || !height) return;
|
||||
view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) };
|
||||
if (!fitted) refit();
|
||||
draw();
|
||||
// 크기를 바꾸면 캔버스가 비므로 같은 화면에 바로 그림(빈 화면 한 번 없음)
|
||||
baseDirty = topDirty = true;
|
||||
flush();
|
||||
}
|
||||
|
||||
/** 누른 화면 자리 → 그림 px(돌리기 전). */
|
||||
@@ -270,25 +309,33 @@ 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))) draw();
|
||||
if (layers.some((layer) => layer.press?.(px, py, map, 0))) {
|
||||
canvas.setPointerCapture(event.pointerId); // 끌기가 캔버스 밖으로 나가도 놓음을 받음
|
||||
drawTop();
|
||||
}
|
||||
});
|
||||
canvas.addEventListener("pointerup", (event) => {
|
||||
if (event.button !== 0) return;
|
||||
for (const layer of layers) layer.release?.();
|
||||
drawTop();
|
||||
});
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
if (!meta || event.buttons & 4) return; // 가운데 단추 옮기는 중
|
||||
const [px, py] = at(event);
|
||||
const map = screenMap();
|
||||
for (const layer of layers) layer.hover?.(px, py, map);
|
||||
draw();
|
||||
drawTop();
|
||||
});
|
||||
canvas.addEventListener("pointerleave", () => {
|
||||
for (const layer of layers) layer.leave?.();
|
||||
draw();
|
||||
drawTop();
|
||||
});
|
||||
canvas.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
if (!meta) return;
|
||||
const [px, py] = at(event);
|
||||
const map = screenMap();
|
||||
if (layers.some((layer) => layer.press?.(px, py, map, 2))) draw();
|
||||
if (layers.some((layer) => layer.press?.(px, py, map, 2))) drawTop();
|
||||
});
|
||||
bindRouteEditNavigation({
|
||||
canvas,
|
||||
@@ -322,7 +369,8 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
if (tools.measure !== undefined)
|
||||
measureButton.setAttribute("aria-pressed", String(tools.measure));
|
||||
},
|
||||
redraw: draw,
|
||||
redraw: drawTop,
|
||||
redrawBase: draw,
|
||||
fit() {
|
||||
refit();
|
||||
draw();
|
||||
@@ -333,6 +381,9 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
return rotation.rerotate(x, y);
|
||||
},
|
||||
rotationDeg: () => (rotation.radians() * 180) / Math.PI,
|
||||
dispose: () => observer.disconnect(),
|
||||
dispose: () => {
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(frame);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Page.ts
|
||||
* D 예정노선 2단계 노선분석(요구서 389 3장 6~13 · PLAN 562-3 · 563-1) — B05~B09 틀(`mountGroupStep`).
|
||||
* D 예정노선 2단계 노선분석(요구서 389 3장 6~13 · 440 · PLAN 562-3 · 563-1 · 579-3 · 580) — B05~B09 틀(`mountGroupStep`).
|
||||
*
|
||||
* 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 구역 마크) + 도구 여섯.
|
||||
* 좌 = 지형(범위를 카카오 지도에 먼저 보임 → [이 범위 받기] · [범위 밖 더 받기]는 선이 받은 범위 밖일 때만) ·
|
||||
* 그리기(본선 · 갈래 · 구역 마크 · 제약 칸) · 구역 · 연도(조각마다 연도) · [저장] [초기화].
|
||||
* 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 연도 구분) + 도구 여섯.
|
||||
* 좌 = 지형(들어올 때 받은 범위가 시점 · 종점을 못 덮으면 바로 받기 · [범위 밖 더 받기]는 시점 · 종점 · 선이
|
||||
* 받은 범위 밖일 때만) · 그리기([긋기] · [연도 구분] · 제약 칸 · 긋는 해 · 고른 선) ·
|
||||
* 구역 · 연도(선 통째 해 · 조각마다 해 · 고른 줄 밝힘) · [저장] [초기화].
|
||||
* 시점 · 종점 = D02 목록(여럿) · 고르기 · 고치기 = `D03_PlanAnalysis_UI_Edit`.
|
||||
*
|
||||
* 데이터 3층(CLAUDE.md 5장) — 긋기 · 마크 · 연도 = 캐시(`d03-plan` {version: 2, network}) · [저장] =
|
||||
* PUT plan/network(서버가 길이 · 측점 · 조각 · 연도 행을 다시 셈 · 응답으로 바꿈) · [초기화] = 빈 선 PUT.
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
} from "./D03_PlanAnalysis_Api";
|
||||
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
|
||||
import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw";
|
||||
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";
|
||||
@@ -53,9 +56,10 @@ import { lineLength, stationChainages } from "./logic/D03_PlanAnalysis_Draw";
|
||||
import {
|
||||
emptyNetwork,
|
||||
fromServer,
|
||||
lineName,
|
||||
lineNames,
|
||||
marksOf,
|
||||
normalizePieces,
|
||||
setLineYear,
|
||||
setPieceYear,
|
||||
type PlanNetwork,
|
||||
type Vertex,
|
||||
@@ -82,11 +86,12 @@ const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M,
|
||||
export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const projectId = currentProjectId();
|
||||
let editable = false;
|
||||
let start: Vertex = [0, 0];
|
||||
let end: Vertex = [0, 0];
|
||||
let starts: Vertex[] = [];
|
||||
let ends: Vertex[] = [];
|
||||
let epsg = 0;
|
||||
let yearStart = new Date().getFullYear();
|
||||
let network: PlanNetwork = emptyNetwork(start);
|
||||
let drawYear = yearStart; // 긋는 해 — 새 조각 처음 해
|
||||
let network: PlanNetwork = emptyNetwork();
|
||||
let savedJson = JSON.stringify(network); // 못 읽은 채 떠남 = 고침 없음
|
||||
let undoStack: PlanNetwork[] = [];
|
||||
let redoStack: PlanNetwork[] = [];
|
||||
@@ -99,14 +104,31 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
|
||||
const z = (x: number, y: number): number => (surface ? gridHeightAt(surface.grid, x, y) : NaN);
|
||||
const draw = createPlanDraw({
|
||||
base: () => ({ start, end }),
|
||||
base: () => ({ starts, ends }),
|
||||
network: () => network,
|
||||
heightAt: () => z,
|
||||
commit,
|
||||
year: () => drawYear,
|
||||
onMode: () => {
|
||||
if (draw.mode() !== "idle") setMeasure(false);
|
||||
refresh();
|
||||
},
|
||||
onMark: (piece) => {
|
||||
edit.select(network.pieces.find((p) => p.id === piece)?.line ?? null, piece);
|
||||
openYear(piece);
|
||||
},
|
||||
});
|
||||
const edit = createPlanEdit({
|
||||
network: () => network,
|
||||
base: () => ({ starts, ends }),
|
||||
commit,
|
||||
show: (next) => {
|
||||
network = normalizePieces(next, drawYear);
|
||||
map.redraw();
|
||||
},
|
||||
enabled: () => draw.mode() === "idle" && !measuring,
|
||||
editable: () => editable,
|
||||
onSelect: () => refresh(),
|
||||
});
|
||||
const measure = createPlanMeasure(() => z);
|
||||
const map = createPlanMap({
|
||||
@@ -118,7 +140,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const backdrop = createPlanLayers({
|
||||
projectId,
|
||||
map,
|
||||
points: () => [start, end, ...allVertices()],
|
||||
points: () => [...starts, ...ends, ...allVertices()],
|
||||
});
|
||||
map.root.append(backdrop.bar, backdrop.legend);
|
||||
const main = document.createElement("div");
|
||||
@@ -143,8 +165,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
});
|
||||
const terrainCard = createPanelCard("지형", false);
|
||||
terrainCard.append(terrainText, bar.root, extentButton, moreButton);
|
||||
const drawYearPick = createSelectField({
|
||||
label: "긋는 해",
|
||||
options: [],
|
||||
size: 24,
|
||||
ariaLabel: "긋는 해",
|
||||
onChange: (value) => (drawYear = Number(value)),
|
||||
});
|
||||
drawYearPick.select.dataset.role = "draw-year";
|
||||
const drawCard = createPanelCard("그리기", false);
|
||||
drawCard.append(draw.panel);
|
||||
drawCard.append(draw.panel, drawYearPick.root, edit.panel);
|
||||
const pieceList = document.createElement("div");
|
||||
pieceList.className = "d03-plan__pieces";
|
||||
const pieceCard = createPanelCard("구역 · 연도", false);
|
||||
@@ -183,7 +213,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
function commit(next: PlanNetwork): void {
|
||||
undoStack = [...undoStack.slice(1 - UNDO_MAX), network];
|
||||
redoStack = [];
|
||||
network = normalizePieces(next, yearStart);
|
||||
network = normalizePieces(next, drawYear);
|
||||
keepDraft();
|
||||
refresh();
|
||||
}
|
||||
@@ -211,17 +241,25 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
measuring = on;
|
||||
measure.clear();
|
||||
if (on) draw.setMode("idle");
|
||||
map.setLayers([...backdrop.layers, draw.layer, ...(measuring ? [measure.layer] : [])]);
|
||||
map.setLayers([
|
||||
...backdrop.layers,
|
||||
draw.layer,
|
||||
edit.layer,
|
||||
...(measuring ? [measure.layer] : []),
|
||||
]);
|
||||
refresh();
|
||||
}
|
||||
|
||||
/** 꺾임점이 받은 범위 밖인가(받은 범위 없음 = 밖 아님 — 그땐 [지형 범위 보기]). */
|
||||
/** 시점 · 종점 · 꺾임점이 받은 범위 밖인가(받은 범위 없음 = 밖 아님 — 그땐 [지형 범위 보기]). */
|
||||
const outside = (): boolean =>
|
||||
!!have &&
|
||||
allVertices().some(([x, y]) => x < have![0] || x > have![2] || y < have![1] || y > have![3]);
|
||||
[...starts, ...ends, ...allVertices()].some(
|
||||
([x, y]) => x < have![0] || x > have![2] || y < have![1] || y > have![3],
|
||||
);
|
||||
|
||||
function refresh(): void {
|
||||
draw.sync();
|
||||
edit.sync();
|
||||
map.setTools({
|
||||
undo: editable && undoStack.length > 0,
|
||||
redo: editable && redoStack.length > 0,
|
||||
@@ -238,7 +276,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
: !surface
|
||||
? "받은 지형 없음 — 범위를 보고 받으세요(미리보기 길이 · 기울기는 지형을 받은 뒤)."
|
||||
: outside()
|
||||
? "그은 선이 받은 지형 밖으로 나갔습니다 — 더 받아야 기울기를 셉니다."
|
||||
? "시점 · 종점 또는 그은 선이 받은 지형 밖입니다 — 더 받아야 기울기를 셉니다."
|
||||
: "받은 지형 안";
|
||||
if (failed && editable && !busy) extentButton.hidden = false;
|
||||
extentButton.querySelector(".ui-btn__label")!.textContent = failed
|
||||
@@ -248,23 +286,59 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
saveButton.disabled = busy || !dirty();
|
||||
}
|
||||
|
||||
function renderPieces(): void {
|
||||
/** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */
|
||||
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));
|
||||
const options = [...years].sort().map((year) => ({ value: String(year), text: `${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 head = createText(
|
||||
`${lineName(index)} · ${lineLength(line.vertices).toFixed(1)} m`,
|
||||
"muted",
|
||||
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,
|
||||
);
|
||||
head.classList.add("d03-plan__piece-head");
|
||||
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)`,
|
||||
@@ -425,7 +499,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
network =
|
||||
server && server.lines.length
|
||||
? fromServer(server.lines, server.marks, server.pieces, yearStart)
|
||||
: emptyNetwork(start);
|
||||
: emptyNetwork();
|
||||
if (server?.epsg) epsg = server.epsg;
|
||||
savedJson = JSON.stringify(network);
|
||||
}
|
||||
@@ -484,9 +558,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
|
||||
function open3d(): void {
|
||||
if (!surface) return;
|
||||
const names = lineNames(network.lines);
|
||||
const lines = network.lines
|
||||
.map((line, index) => ({
|
||||
name: lineName(index),
|
||||
name: names[index],
|
||||
line: line.vertices,
|
||||
stations: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M),
|
||||
}))
|
||||
@@ -497,9 +572,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
// 검증 창구 — 꼴 · 미리보기 · 제약 · 화면 자리 · 재기 · 회전 · 저장 시간(ORCA 판정)
|
||||
(window as unknown as { __d03Plan?: unknown }).__d03Plan = {
|
||||
network: () => network,
|
||||
base: () => ({ start, end }),
|
||||
base: () => ({ starts, ends }),
|
||||
lineNames: () => lineNames(network.lines),
|
||||
screenOf: map.screenOf,
|
||||
preview: draw.preview,
|
||||
stroke: draw.stroke,
|
||||
selected: edit.selected,
|
||||
select: edit.select,
|
||||
drawYear: () => drawYear,
|
||||
constraint: draw.constraint,
|
||||
setConstraint: draw.setConstraint,
|
||||
mode: draw.mode,
|
||||
@@ -525,36 +605,44 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
? (redo(), true)
|
||||
: measuring && event.key === "Escape"
|
||||
? (setMeasure(false), true)
|
||||
: !ctrl && draw.key(event);
|
||||
: !ctrl && (draw.key(event) || edit.key(event));
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
map.redraw();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
map.setLayers([...backdrop.layers, draw.layer]);
|
||||
map.setLayers([...backdrop.layers, draw.layer, edit.layer]);
|
||||
|
||||
try {
|
||||
editable = !!(await fetchSessionUser())?.is_forest_service;
|
||||
draw.setEditable(editable);
|
||||
const view = await fetchPlanNetwork(projectId);
|
||||
const base = view.base;
|
||||
if (!base.start || !base.end || !base.epsg) {
|
||||
if (!base.starts.length || !base.ends.length || !base.epsg) {
|
||||
terrainText.textContent = "시점 · 종점이 비었습니다 — 1단계(기준 지정)에서 정하세요.";
|
||||
return;
|
||||
}
|
||||
start = [base.start.x, base.start.y];
|
||||
end = [base.end.x, base.end.y];
|
||||
starts = base.starts.map((p): Vertex => [p.x, p.y]);
|
||||
ends = base.ends.map((p): Vertex => [p.x, p.y]);
|
||||
epsg = base.epsg;
|
||||
takeServer(view);
|
||||
drawYear = yearStart;
|
||||
drawYearPick.setOptions(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: [start, end, ...allVertices()] });
|
||||
map.update({ fit: [...starts, ...ends, ...allVertices()] });
|
||||
refresh();
|
||||
await loadSurface();
|
||||
refresh();
|
||||
await backdrop.load();
|
||||
if ((await fetchPlanProgress(projectId)).terrain.running) await follow();
|
||||
const job = (await fetchPlanProgress(projectId)).terrain;
|
||||
if (job.running) await follow();
|
||||
else if (editable && !job.error) {
|
||||
// 들어올 때 받은 범위가 시점 · 종점을 못 덮으면 바로 받기(요구 441 1 · 실패한 뒤엔 [다시 받기])
|
||||
const extent = await fetchTerrainExtent(projectId, null, []);
|
||||
if (!extent.covered) await download(extent.margin_m, []);
|
||||
}
|
||||
} catch (error) {
|
||||
terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,17 @@
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* 582 바탕 캔버스 — 위 캔버스 밑에 겹침 · 누름은 위가 받음 */
|
||||
.d03-plan__map .d03-plan__base {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.d03-plan__map canvas:not(.d03-plan__base) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 571-1 — 왼쪽은 겹 단추줄(폭 = JS `--d03-layers-w`) 오른쪽까지 · 모자라면 도구줄이 가로 밀림 · 오른쪽 붙임 그대로 */
|
||||
.d03-plan__tools {
|
||||
position: absolute;
|
||||
@@ -133,3 +144,9 @@
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* 580-3 고른 선 · 조각 줄 밝힘 */
|
||||
.d03-plan__piece.is-selected {
|
||||
background: var(--color-accent-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_Plan.ts
|
||||
* D 예정노선 2단계 손 긋기 꼴(PLAN 562-3) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기.
|
||||
* D 예정노선 2단계 손 긋기 꼴(PLAN 562-3 · 580) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기 ·
|
||||
* 선 뒤집기 · 잇기 · 꺾임점 옮김 · 지움 · 선 지움 · 선 통째 연도 · 마크 옮김.
|
||||
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
|
||||
* [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { lineLength } from "./D03_PlanAnalysis_Draw";
|
||||
import { lineLength, segmentLength } from "./D03_PlanAnalysis_Draw";
|
||||
|
||||
export type Vertex = [number, number];
|
||||
|
||||
/** 그은 선 하나 — 첫 선 = 본선(D02 시점에서) · 둘째부터 = 갈래(`branch` = 붙은 선 · 꺾임점 차례). */
|
||||
/** 그은 선 하나 — `branch` 없음 = 본선(시점 · 종점에서) · 있음 = 갈래(붙은 선 · 꺾임점 차례) ·
|
||||
* 꼭짓점 차례 = 측점 차례(첫 점 = No.0). */
|
||||
export interface PlanLine {
|
||||
id: string;
|
||||
vertices: Vertex[];
|
||||
@@ -32,8 +34,11 @@ export interface PlanNetwork {
|
||||
|
||||
/** 마크가 끝 · 다른 마크와 이보다 가까우면 안 놓음(m). */
|
||||
export const MARK_GAP_M = 1;
|
||||
/** 같은 점(m) — 서버 1 mm 와 같음. */
|
||||
const SAME_M = 1e-3;
|
||||
|
||||
const round3 = (value: number): number => Math.round(value * 1000) / 1000;
|
||||
export const samePoint = (a: Vertex, b: Vertex): boolean => segmentLength(a, b) <= SAME_M;
|
||||
|
||||
/** 새 조각 id — `p<n>`(있는 것 중 가장 큰 수 + 1). */
|
||||
function nextPieceId(pieces: ReadonlyArray<PlanPiece>): string {
|
||||
@@ -41,9 +46,28 @@ function nextPieceId(pieces: ReadonlyArray<PlanPiece>): string {
|
||||
return `p${Math.max(0, ...used) + 1}`;
|
||||
}
|
||||
|
||||
/** 빈 선 — 본선 시점만. */
|
||||
export function emptyNetwork(start: Vertex): PlanNetwork {
|
||||
return { lines: [{ id: "main", vertices: [start], branch: null }], pieces: [] };
|
||||
/** 빈 꼴 — 선 없음(긋기 = 시점 · 종점 · 선에서 시작). */
|
||||
export function emptyNetwork(): PlanNetwork {
|
||||
return { lines: [], pieces: [] };
|
||||
}
|
||||
|
||||
/** 새 선 id — 처음 본선 = `main` · 그 밖 `<prefix>-<n>`. */
|
||||
function nextLineId(lines: ReadonlyArray<PlanLine>, prefix: "line" | "branch"): string {
|
||||
const used = new Set(lines.map((entry) => entry.id));
|
||||
if (prefix === "line" && !used.has("main")) return "main";
|
||||
let n = lines.length;
|
||||
while (used.has(`${prefix}-${n}`)) n += 1;
|
||||
return `${prefix}-${n}`;
|
||||
}
|
||||
|
||||
/** 새 본선 — `point` 한 점(끝에 꺾임점을 더해 감). */
|
||||
export function newLine(network: PlanNetwork, point: Vertex): PlanNetwork {
|
||||
const line: PlanLine = {
|
||||
id: nextLineId(network.lines, "line"),
|
||||
vertices: [[point[0], point[1]]],
|
||||
branch: null,
|
||||
};
|
||||
return { ...network, lines: [...network.lines, line] };
|
||||
}
|
||||
|
||||
/** 선마다 조각이 0 ~ 길이를 빈틈없이 덮게 맞춤 — 경계 = 조각 시작(0 · 끝 근처 빼고) · 연도 = 그 자리 조각 ·
|
||||
@@ -186,14 +210,12 @@ export function dropLastLine(network: PlanNetwork): PlanNetwork {
|
||||
export function addBranch(network: PlanNetwork, line: number, vertex: number): PlanNetwork {
|
||||
const parent = network.lines[line];
|
||||
const from = parent.vertices[vertex];
|
||||
const used = new Set(network.lines.map((entry) => entry.id));
|
||||
let n = network.lines.length;
|
||||
while (used.has(`branch-${n}`)) n += 1;
|
||||
const id = nextLineId(network.lines, "branch");
|
||||
return {
|
||||
...network,
|
||||
lines: [
|
||||
...network.lines,
|
||||
{ id: `branch-${n}`, vertices: [[from[0], from[1]]], branch: { line: parent.id, vertex } },
|
||||
{ id, vertices: [[from[0], from[1]]], branch: { line: parent.id, vertex } },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -223,14 +245,262 @@ export function marksOf(network: PlanNetwork): Array<{ line: string; at_m: numbe
|
||||
.map((piece) => ({ line: piece.line, at_m: piece.from_m }));
|
||||
}
|
||||
|
||||
/** 본선이 종점에 닿았나(1 mm). */
|
||||
export function reachedEnd(network: PlanNetwork, end: Vertex): boolean {
|
||||
const main = network.lines[0]?.vertices ?? [];
|
||||
const last = main[main.length - 1];
|
||||
return !!last && Math.hypot(last[0] - end[0], last[1] - end[1]) < 1e-3;
|
||||
/** 선 이름 — 갈래 없는 선 = 본선(둘 이상이면 본선 1 · 2 …) · 갈래 = 갈래 1 · 2 …(선 차례).
|
||||
* ⚠ 짝 = `D04_PlanConfirm_Repository.line_names`(조각 · 내보내기 이름) — 고치면 같이(거울 시험 530-3). */
|
||||
export function lineNames(lines: ReadonlyArray<Pick<PlanLine, "branch">>): string[] {
|
||||
const roots = lines.filter((line) => !line.branch).length;
|
||||
let root = 0;
|
||||
let branch = 0;
|
||||
return lines.map((line) =>
|
||||
line.branch ? `갈래 ${++branch}` : roots > 1 ? `본선 ${++root}` : "본선",
|
||||
);
|
||||
}
|
||||
|
||||
/** 선 이름 — 본선 · 갈래 1 · 갈래 2 …(선 차례). */
|
||||
export function lineName(index: number): string {
|
||||
return index === 0 ? "본선" : `갈래 ${index}`;
|
||||
/** 선 차례 — 갈래는 부모 뒤(서버 셈이 앞에서부터 부모를 찾음) · 이미 맞으면 그대로. */
|
||||
export function orderLines(lines: ReadonlyArray<PlanLine>): PlanLine[] {
|
||||
const done = new Set<string>();
|
||||
const out: PlanLine[] = [];
|
||||
let rest = [...lines];
|
||||
while (rest.length) {
|
||||
const left: PlanLine[] = [];
|
||||
for (const line of rest) {
|
||||
if (!line.branch || done.has(line.branch.line)) {
|
||||
out.push(line);
|
||||
done.add(line.id);
|
||||
} else left.push(line);
|
||||
}
|
||||
if (left.length === rest.length) return [...out, ...left];
|
||||
rest = left;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `id` 선과 그 아래 갈래 모두. */
|
||||
function family(network: PlanNetwork, id: string): Set<string> {
|
||||
const out = new Set([id]);
|
||||
for (let grew = true; grew;) {
|
||||
grew = false;
|
||||
for (const line of network.lines)
|
||||
if (line.branch && out.has(line.branch.line) && !out.has(line.id)) {
|
||||
out.add(line.id);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 꺾임점마다 선 위 거리. */
|
||||
function chainages(vertices: ReadonlyArray<Vertex>): number[] {
|
||||
const out = [0];
|
||||
for (let i = 1; i < vertices.length; i += 1)
|
||||
out.push(out[i - 1] + segmentLength(vertices[i - 1], vertices[i]));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 선 모양이 바뀜 — 그 선 마크(조각 시작)를 `carry(옛 거리)` 로 옮겨 실음 · 끝은 `normalizePieces` 가 맞춤. */
|
||||
function carryMarks(
|
||||
pieces: ReadonlyArray<PlanPiece>,
|
||||
line: string,
|
||||
carry: (m: number) => number,
|
||||
): PlanPiece[] {
|
||||
return pieces.map((piece) =>
|
||||
piece.line === line && piece.from_m > 0
|
||||
? { ...piece, from_m: round3(carry(piece.from_m)) }
|
||||
: piece,
|
||||
);
|
||||
}
|
||||
|
||||
/** 옛 모양 위 거리 → 같은 구간 · 같은 비율의 새 모양 위 거리(꺾임점 수 같음). */
|
||||
function sameShare(
|
||||
before: ReadonlyArray<Vertex>,
|
||||
after: ReadonlyArray<Vertex>,
|
||||
): (m: number) => number {
|
||||
const [a, b] = [chainages(before), chainages(after)];
|
||||
return (m) => {
|
||||
const found = a.findIndex((s, k) => k > 0 && m <= s);
|
||||
const i = found < 0 ? a.length - 2 : found - 1;
|
||||
const span = a[i + 1] - a[i];
|
||||
return b[i] + (span > 0 ? (m - a[i]) / span : 0) * (b[i + 1] - b[i]);
|
||||
};
|
||||
}
|
||||
|
||||
/** 꺾임점 옮김 — 이 점에 붙은 갈래 첫 점도 같이 · 마크는 구간 안 비율 그대로. */
|
||||
export function moveVertex(
|
||||
network: PlanNetwork,
|
||||
line: number,
|
||||
vertex: number,
|
||||
point: Vertex,
|
||||
): PlanNetwork {
|
||||
const moved = new Map<string, number>(); // 선 id → 옮길 꺾임점
|
||||
const visit = (id: string, v: number): void => {
|
||||
moved.set(id, v);
|
||||
for (const other of network.lines)
|
||||
if (other.branch?.line === id && other.branch.vertex === v) visit(other.id, 0);
|
||||
};
|
||||
visit(network.lines[line].id, vertex);
|
||||
let pieces = network.pieces;
|
||||
const lines = network.lines.map((entry) => {
|
||||
const v = moved.get(entry.id);
|
||||
if (v === undefined) return entry;
|
||||
const vertices = entry.vertices.map((p, i): Vertex => (i === v ? [point[0], point[1]] : p));
|
||||
pieces = carryMarks(pieces, entry.id, sameShare(entry.vertices, vertices));
|
||||
return { ...entry, vertices };
|
||||
});
|
||||
return { lines, pieces };
|
||||
}
|
||||
|
||||
/** 꺾임점 지움 — 갈래 첫 점 · 갈래가 붙은 점 · 점 둘뿐인 선은 못 지움(까닭 글) · 뒤 갈래 차례 −1 ·
|
||||
* 마크는 합친 구간 안 비율. */
|
||||
export function removeVertex(
|
||||
network: PlanNetwork,
|
||||
line: number,
|
||||
vertex: number,
|
||||
): PlanNetwork | string {
|
||||
const entry = network.lines[line];
|
||||
if (entry.vertices.length <= 2) return "점이 둘뿐인 선입니다 — 선을 지우세요.";
|
||||
if (vertex === 0 && entry.branch) return "갈래 첫 점은 붙은 선 위라 지울 수 없습니다.";
|
||||
if (network.lines.some((o) => o.branch?.line === entry.id && o.branch.vertex === vertex))
|
||||
return "갈래가 붙은 점은 지울 수 없습니다 — 갈래를 먼저 지우세요.";
|
||||
const vertices = entry.vertices.filter((_, i) => i !== vertex);
|
||||
const [a, b] = [chainages(entry.vertices), chainages(vertices)];
|
||||
const last = entry.vertices.length - 1;
|
||||
const carry = (m: number): number => {
|
||||
if (vertex === 0) return Math.max(0, m - a[1]);
|
||||
if (vertex === last) return Math.min(m, b[b.length - 1]);
|
||||
if (m <= a[vertex - 1]) return m;
|
||||
if (m >= a[vertex + 1]) return m - a[vertex + 1] + b[vertex];
|
||||
const share = (m - a[vertex - 1]) / (a[vertex + 1] - a[vertex - 1]);
|
||||
return b[vertex - 1] + share * (b[vertex] - b[vertex - 1]);
|
||||
};
|
||||
return {
|
||||
lines: network.lines.map((other, i) => {
|
||||
if (i === line) return { ...other, vertices };
|
||||
const branch = other.branch;
|
||||
return branch?.line === entry.id && branch.vertex > vertex
|
||||
? { ...other, branch: { ...branch, vertex: branch.vertex - 1 } }
|
||||
: other;
|
||||
}),
|
||||
pieces: carryMarks(network.pieces, entry.id, carry),
|
||||
};
|
||||
}
|
||||
|
||||
/** 선 방향 바꿈(측점 No.0 쪽 바꿈) — 조각 · 붙은 갈래 차례 따라감 · 갈래는 못 바꿈(까닭 글). */
|
||||
export function reverseLine(network: PlanNetwork, line: number): PlanNetwork | string {
|
||||
const entry = network.lines[line];
|
||||
if (entry.branch) return "갈래는 방향을 바꿀 수 없습니다 — 첫 점이 붙은 선 위입니다.";
|
||||
const n = entry.vertices.length;
|
||||
const length = round3(lineLength(entry.vertices));
|
||||
return {
|
||||
lines: network.lines.map((other, i) => {
|
||||
if (i === line) return { ...other, vertices: [...other.vertices].reverse() };
|
||||
const branch = other.branch;
|
||||
return branch?.line === entry.id
|
||||
? { ...other, branch: { ...branch, vertex: n - 1 - branch.vertex } }
|
||||
: other;
|
||||
}),
|
||||
pieces: network.pieces.map((piece) =>
|
||||
piece.line === entry.id
|
||||
? { ...piece, from_m: round3(length - piece.to_m), to_m: round3(length - piece.from_m) }
|
||||
: piece,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 선 지움 — 그 선 조각도 · 갈래가 붙어 있으면 못 지움(까닭 글). */
|
||||
export function removeLine(network: PlanNetwork, line: number): PlanNetwork | string {
|
||||
const id = network.lines[line]?.id;
|
||||
if (network.lines.some((other) => other.branch?.line === id))
|
||||
return "갈래가 붙은 선은 지울 수 없습니다 — 갈래를 먼저 지우세요.";
|
||||
return {
|
||||
lines: network.lines.filter((_, i) => i !== line),
|
||||
pieces: network.pieces.filter((piece) => piece.line !== id),
|
||||
};
|
||||
}
|
||||
|
||||
/** `a` 선 꼬리에 `b` 선(머리 = a 꼬리)을 이어 한 선 — 조각 id 그대로 · 이음 자리 두 조각이 같은 해면 하나로. */
|
||||
function joinLines(network: PlanNetwork, a: number, b: number): PlanNetwork {
|
||||
const [head, tail] = [network.lines[a], network.lines[b]];
|
||||
const offset = round3(lineLength(head.vertices));
|
||||
const shift = head.vertices.length - 1;
|
||||
const last = network.pieces
|
||||
.filter((piece) => piece.line === head.id)
|
||||
.sort((x, y) => y.from_m - x.from_m)[0];
|
||||
const pieces = network.pieces
|
||||
.map((piece) =>
|
||||
piece.line === tail.id
|
||||
? {
|
||||
...piece,
|
||||
line: head.id,
|
||||
from_m: round3(piece.from_m + offset),
|
||||
to_m: round3(piece.to_m + offset),
|
||||
}
|
||||
: piece,
|
||||
)
|
||||
.filter(
|
||||
(piece) =>
|
||||
!(
|
||||
piece.line === head.id &&
|
||||
Math.abs(piece.from_m - offset) < SAME_M &&
|
||||
last?.year === piece.year
|
||||
),
|
||||
);
|
||||
const lines = network.lines
|
||||
.map((line, i) => {
|
||||
if (i === a) return { ...line, vertices: [...line.vertices, ...tail.vertices.slice(1)] };
|
||||
const branch = line.branch;
|
||||
return branch?.line === tail.id
|
||||
? { ...line, branch: { line: head.id, vertex: branch.vertex + shift } }
|
||||
: line;
|
||||
})
|
||||
.filter((_, i) => i !== b);
|
||||
return { lines: orderLines(lines), pieces };
|
||||
}
|
||||
|
||||
/** 갈래 없는 선 머리가 다른 선 꼬리에 닿으면 이어 붙이고 · 꺾임점에 닿으면 그 점의 갈래로(제 갈래 빼고). */
|
||||
export function attachHead(network: PlanNetwork, line: number): PlanNetwork {
|
||||
const entry = network.lines[line];
|
||||
if (!entry || entry.branch || entry.vertices.length < 2) return network;
|
||||
const head = entry.vertices[0];
|
||||
const own = family(network, entry.id);
|
||||
const others = network.lines
|
||||
.map((other, index) => ({ other, index }))
|
||||
.filter(({ other }) => !own.has(other.id) && other.vertices.length > 0);
|
||||
const tail = others.find(({ other }) =>
|
||||
samePoint(other.vertices[other.vertices.length - 1], head),
|
||||
);
|
||||
if (tail) return joinLines(network, tail.index, line);
|
||||
for (const { other } of others) {
|
||||
const vertex = other.vertices.findIndex((v) => samePoint(v, head));
|
||||
if (vertex < 0) continue;
|
||||
const lines = network.lines.map((entry, i) =>
|
||||
i === line ? { ...entry, branch: { line: other.id, vertex } } : entry,
|
||||
);
|
||||
return { ...network, lines: orderLines(lines) };
|
||||
}
|
||||
return network;
|
||||
}
|
||||
|
||||
/** 선 통째 연도 — 그 선 조각 모두. */
|
||||
export function setLineYear(network: PlanNetwork, line: string, year: number): PlanNetwork {
|
||||
return {
|
||||
...network,
|
||||
pieces: network.pieces.map((piece) => (piece.line === line ? { ...piece, year } : piece)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 마크 옮김 — `index` 조각의 시작(= 앞 조각 끝)을 `atM` 으로 · 양쪽 조각 안에서 `MARK_GAP_M` 남김. */
|
||||
export function moveMark(network: PlanNetwork, index: number, atM: number): PlanNetwork {
|
||||
const piece = network.pieces[index];
|
||||
if (!piece || !(piece.from_m > 0)) return network;
|
||||
const before = network.pieces.find(
|
||||
(p) => p !== piece && p.line === piece.line && Math.abs(p.to_m - piece.from_m) < SAME_M,
|
||||
);
|
||||
if (!before) return network;
|
||||
const at = round3(Math.min(piece.to_m - MARK_GAP_M, Math.max(before.from_m + MARK_GAP_M, atM)));
|
||||
return {
|
||||
...network,
|
||||
pieces: network.pieces.map((p) =>
|
||||
p === piece ? { ...p, from_m: at } : p === before ? { ...p, to_m: at } : p,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,8 +41,24 @@ async def list_year_routes(project_id: str) -> list[dict[str, Any]]:
|
||||
return rows
|
||||
|
||||
|
||||
def line_names(lines: list[dict[str, Any]]) -> list[str]:
|
||||
"""선 이름 — 갈래 없는 선 = 본선(둘 이상이면 본선 1 · 2 …) · 갈래 = 갈래 1 · 2 …(선 차례).
|
||||
|
||||
⚠ 짝 = TS `D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts` `lineNames` — 거울 시험 530-3."""
|
||||
roots = sum(1 for line in lines if not line.get("branch"))
|
||||
out, root, branch = [], 0, 0
|
||||
for line in lines:
|
||||
if line.get("branch"):
|
||||
branch += 1
|
||||
out.append(f"갈래 {branch}")
|
||||
else:
|
||||
root += 1
|
||||
out.append(f"본선 {root}" if roots > 1 else "본선")
|
||||
return out
|
||||
|
||||
|
||||
async def list_pieces(project_id: str) -> list[dict[str, Any]]:
|
||||
"""조각 행(연도 · 선 차례 · 선 위 거리 차례) + `line_name`(본선 · 갈래 N = 정본 선 차례)."""
|
||||
"""조각 행(연도 · 선 차례 · 선 위 거리 차례) + `line_name`(`line_names` = 정본 선 차례)."""
|
||||
async with (
|
||||
get_db_pool().acquire() as connection,
|
||||
connection.cursor(aiomysql.DictCursor) as cursor,
|
||||
@@ -61,13 +77,13 @@ async def list_pieces(project_id: str) -> list[dict[str, Any]]:
|
||||
if network:
|
||||
raw = network["network_json"]
|
||||
body = json.loads(raw) if isinstance(raw, str | bytes) else raw
|
||||
lines = [line["id"] for line in (body or {}).get("lines", [])]
|
||||
order = {line: i for i, line in enumerate(lines)}
|
||||
lines = (body or {}).get("lines", [])
|
||||
order = {line["id"]: i for i, line in enumerate(lines)}
|
||||
names = dict(zip(order, line_names(lines)))
|
||||
for row in rows:
|
||||
raw = row["route_json"]
|
||||
row["route_json"] = json.loads(raw) if isinstance(raw, str | bytes) else raw
|
||||
index = order.get(row["line_id"], len(order))
|
||||
row["line_name"] = "본선" if index == 0 else f"갈래 {index}"
|
||||
row["line_name"] = names.get(row["line_id"], "갈래")
|
||||
rows.sort(key=lambda r: (r["year"], order.get(r["line_id"], len(order)), r["from_m"]))
|
||||
return rows
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# PLAN 579 이관 (2026-10-29 · 요구 440 · 441)
|
||||
|
||||
## 579. D02 기준지정 — 복수 시점 · 종점 (요구 440-1 · 2 · 승인 대기)
|
||||
|
||||
- 정함(브레인): 근거 `docs/raw/요구_1025/요구서_440_예정노선_설계.md` 3 · 4장 · `_분석_440-A` · 저장 = `plan_base_json` 같은 칸에 시점 목록 · 종점 목록(옛 값 읽을 때 감쌈) · 붉은 선 지움 · D03 시종점 여덟 곳 · 받은 범위 밖이면 들어올 때 받기(결정 1).
|
||||
- 사용자 확정(요구 441): 1 = D03 들어올 때 받은 범위가 지점을 못 덮으면 바로 받기(진행 막대) · 2 = 시점 · 종점 따로 목록(최소 1) · 점에 연도 · 잇기 표시 없음.
|
||||
- 이음(sub1): `GET/PUT …/plan/points` = `{epsg, starts: [{lon, lat, x, y}], ends: [...]}`(몸 `{starts: [{lon, lat}], ends: [...]}`) · 옛 `start` · `end` 칸 없앰 · 저장 칸 `plan_base_json` = `{epsg, starts: [{x, y}], ends: [...]}`(옛 꼴은 읽을 때 감쌈) · 선 이름 = 갈래 없는 선 「본선」(둘 이상이면 「본선 N」) · 갈래 「갈래 N」(D04 같음).
|
||||
- [x] 579-1 서버 꼴(`D02_PlanBase_Router.py` · `D03_PlanAnalysis_Router.py` · `D03_PlanAnalysis_Network.py`) → 579-2 기준지정 화면 → 579-3 D03 받는 꼴 · 자동 받기 · 범위 밖 표시.
|
||||
- 검증완료 — D02 시점 2 · 종점 2 더함 · 줄 지움 2 · 저장 API starts 2 · ends 2 · 새로고침 같음 · 잇는 선 0 · D03 범위 밖 종점(북 600 m) = 들어오고 3.1 s 에 받기 시작 · 34 s 끝 · covered 참 · 시험 33 통과
|
||||
@@ -0,0 +1,8 @@
|
||||
# PLAN 580 이관 (2026-10-29 · 요구 440 · 441)
|
||||
|
||||
## 580. D03 노선 그리기 — 양끝 · 부분 수정 · 각도 · 연도 (요구 440-3~7 · 승인 대기)
|
||||
|
||||
- 정함(브레인): 근거 `_분석_440-B` · 저장 꼴 바꿈 없음 · [긋기] 하나(시점 · 종점 · 선 끝 · 선 위) · 종점 긋기는 끝낼 때 뒤집음 · 꺾임점 끌기 · 더하기 · 지우기 · 미리보기 각도 · 각도 고정 · 선 고르기 · 지우기 · 선 통째 연도 · 긋는 해 · 마커 연도 · 끌기.
|
||||
- 사용자 확정(요구 441): 「각도」 = 평면 각이 아니라 노선이 놓일 자리의 가상 종단 기울기 → 미리보기 = 거리 + 가상 종단 기울기(% · °) · 「각도 고정」 = 지금 「기울기 고정」(기울기 고정 · 길이 자유 · 562-1) 그대로 쓰고 모자란 것만 · 시점 · 종점(측점 No.0 쪽)은 자동(반대쪽 끝)으로 고르되 사용자가 바꿀 수 있음(방향 바꾸기) · 새 마커 뒤 구간 = 같은 해.
|
||||
- [x] 580-1 긋기 · 부분 수정 → 580-2 각도 글 · 각도 고정 → 580-3 고르기 · 지우기 · 연도 · 마커(579 와 한 창).
|
||||
- 검증완료 — 미리보기 거리 281.780 m · 기울기 1.4393 % = 계산(° = atan) · 기울기 고정 9.000 % · 종점 1 에서 그은 선 → 본선 꼬리 이음(꼭짓점 8→10 · 꼬리 = 종점 1) · 긋는 해 2029 이어 긋기 = 295.6 m 에서 조각 나눔 · 선 위 갈래 · 꺾임점 끌기 오차 0.00004 m · 더함 4→5 · 지움 5→4 · 방향 바꾸기 머리 = 종점 2 · Delete · 선 통째 2028 · 마크 뒤 연도 고름 초점 · 마크 끌기 388.3→428.3 m · [저장] 뒤 새로고침 같음 · D04 내보내기 9 폴더(본선1 · 본선2 · 갈래1) · B03 shp 분석 9 벌 PolyLine · 밝음 · 어두움 · typecheck 0 · 시험 97 통과 · DB 되돌림 같음
|
||||
@@ -0,0 +1,189 @@
|
||||
/* 582 등고선 줌 단계 색인 헬퍼 — `B04_PreProcess_UI_MapRender_Prepare.ts` 의 `indexLayerLod` 를 붙인
|
||||
* 레이어와 안 붙인 레이어를 같은 `drawPreparedLayer` 로 가짜 캔버스에 그려 그리기 명령(차례 · 좌표)을
|
||||
* 줌 · 자리마다 맞대고 JSON 으로 낸다. 글 폭 기억(`drawPreparedLabels` widths)도 같은 방식.
|
||||
*/
|
||||
const path = require("path");
|
||||
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const { load } = makeLoader({ root: ROOT, ui: () => ({ themeColor: () => "" }) });
|
||||
const render = load("B04_PreProcess", "B04_PreProcess_UI_MapRender.ts");
|
||||
const prep = load("B04_PreProcess", "logic", "B04_PreProcess_UI_MapRender_Prepare.ts");
|
||||
|
||||
const W = 1400;
|
||||
const H = 900;
|
||||
const META = { width_meters: 10000, height_meters: 8000 };
|
||||
const NORMALIZER = { lonMin: 127, latMin: 36, lonRange: 0.1, latRange: 0.08, aspect: 1.25 };
|
||||
|
||||
/** 그리기 명령을 글로 적는 가짜 캔버스. */
|
||||
function recorder() {
|
||||
const log = [];
|
||||
const rec =
|
||||
(name) =>
|
||||
(...args) =>
|
||||
log.push(`${name}(${args.map((v) => (typeof v === "number" ? v.toFixed(6) : v)).join(",")})`);
|
||||
let measured = 0;
|
||||
return {
|
||||
log,
|
||||
measured: () => measured,
|
||||
beginPath: rec("beginPath"),
|
||||
moveTo: rec("moveTo"),
|
||||
lineTo: rec("lineTo"),
|
||||
closePath: rec("closePath"),
|
||||
stroke: rec("stroke"),
|
||||
arc: rec("arc"),
|
||||
fill: rec("fill"),
|
||||
save: rec("save"),
|
||||
restore: rec("restore"),
|
||||
translate: rec("translate"),
|
||||
rotate: rec("rotate"),
|
||||
strokeText: rec("strokeText"),
|
||||
fillText: rec("fillText"),
|
||||
measureText: (text) => {
|
||||
measured += 1;
|
||||
return { width: text.length * 6.1 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 정해진 씨앗의 난수(결과가 매번 같게). */
|
||||
function random(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 2 ** 32;
|
||||
};
|
||||
}
|
||||
|
||||
/** 등고선 비슷한 가닥 — 구불구불한 열린 선 · 닫힌 고리 · 짧은 조각. */
|
||||
function collection() {
|
||||
const rnd = random(582);
|
||||
const features = [];
|
||||
const lonLat = (x, y) => [127 + x * 0.1, 36 + (1 - y) * 0.08];
|
||||
for (let f = 0; f < 60; f += 1) {
|
||||
const kind = f % 3; // 0 열린 긴 선 · 1 닫힌 고리 · 2 짧은 조각
|
||||
const n = kind === 2 ? 3 + Math.floor(rnd() * 40) : 200 + Math.floor(rnd() * 1800);
|
||||
const cx = rnd();
|
||||
const cy = rnd();
|
||||
const pts = [];
|
||||
if (kind === 1) {
|
||||
const r = 0.02 + rnd() * 0.2;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const a = (i / n) * Math.PI * 2;
|
||||
const wobble = 1 + 0.15 * Math.sin(a * 7 + f) + 0.01 * (rnd() - 0.5);
|
||||
pts.push(lonLat(cx + r * wobble * Math.cos(a), cy + r * wobble * Math.sin(a)));
|
||||
}
|
||||
pts.push(pts[0]);
|
||||
} else {
|
||||
let x = cx;
|
||||
let y = cy;
|
||||
let heading = rnd() * Math.PI * 2;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
heading += (rnd() - 0.5) * 0.6;
|
||||
const step = 0.0004 + rnd() * 0.0008;
|
||||
x += Math.cos(heading) * step;
|
||||
y += Math.sin(heading) * step;
|
||||
pts.push(lonLat(x, y));
|
||||
}
|
||||
}
|
||||
const geometry =
|
||||
kind === 1
|
||||
? { type: "Polygon", coordinates: [pts] }
|
||||
: f % 5 === 0
|
||||
? { type: "MultiLineString", coordinates: [pts, pts.slice(0, Math.max(2, n >> 1))] }
|
||||
: { type: "LineString", coordinates: pts };
|
||||
features.push({ geometry, properties: { 등고수치: 400 + 5 * f } });
|
||||
}
|
||||
return { features };
|
||||
}
|
||||
|
||||
const plain = prep.prepareLayer(collection(), NORMALIZER, ["등고수치"]);
|
||||
const indexed = prep.indexLayerLod(prep.prepareLayer(collection(), NORMALIZER, ["등고수치"]));
|
||||
|
||||
let lodParts = 0;
|
||||
let shortParts = 0;
|
||||
for (const feature of indexed.features)
|
||||
for (const part of feature.parts) {
|
||||
if (part.lod) lodParts += 1;
|
||||
else shortParts += 1;
|
||||
}
|
||||
|
||||
const SCALES = [1, 1.7, 3, 7, 13, 50, 200, 1000, 2000];
|
||||
const OFFSETS = [
|
||||
[0, 0],
|
||||
[300, -200],
|
||||
[-900, 500],
|
||||
];
|
||||
const mapRect = render.computeMapRect(
|
||||
{ width_meters: META.width_meters, height_meters: META.height_meters },
|
||||
W,
|
||||
H,
|
||||
);
|
||||
const cases = [];
|
||||
for (const scale of SCALES)
|
||||
for (const [ox, oy] of OFFSETS) {
|
||||
const view = { width: W, height: H, scale, offsetX: ox, offsetY: oy, mapRect };
|
||||
const a = recorder();
|
||||
const b = recorder();
|
||||
for (const everyM of [undefined, 25]) {
|
||||
render.drawPreparedLayer(a, plain, view, "dot", everyM);
|
||||
render.drawPreparedLayer(b, indexed, view, "dot", everyM);
|
||||
}
|
||||
const widths = new Map();
|
||||
const la = recorder();
|
||||
const lb = recorder();
|
||||
render.drawPreparedLabels(la, plain, view, "#fff", 5, 0, "#000");
|
||||
render.drawPreparedLabels(lb, indexed, view, "#fff", 5, 0, "#000", widths);
|
||||
const measuredFirst = lb.measured();
|
||||
render.drawPreparedLabels(lb, indexed, view, "#fff", 5, 0, "#000", widths); // 두 번째 = 기억만 씀
|
||||
const measuredSecond = lb.measured() - measuredFirst;
|
||||
const half = lb.log.length / 2;
|
||||
cases.push({
|
||||
scale,
|
||||
offset: [ox, oy],
|
||||
commands: a.log.length,
|
||||
same: a.log.join("\n") === b.log.join("\n"),
|
||||
vertices: a.log.filter((line) => line.startsWith("lineTo")).length,
|
||||
labelsSame: la.log.join("\n") === lb.log.slice(0, half).join("\n"),
|
||||
labelsRepeatSame: lb.log.slice(0, half).join("\n") === lb.log.slice(half).join("\n"),
|
||||
labelMeasuredPlain: la.measured(),
|
||||
labelMeasuredCached: measuredSecond,
|
||||
});
|
||||
}
|
||||
|
||||
// 색인이 훑는 정점 수 — 다 줌아웃(배율 1)의 허용 오차에서 전부 대비 몇 %
|
||||
const tolerance = 0.75 / (mapRect.width * 1);
|
||||
let all = 0;
|
||||
let scanned = 0;
|
||||
for (const feature of indexed.features)
|
||||
for (const part of feature.parts) {
|
||||
all += part.coords.length / 2;
|
||||
const list = part.lod ? prep.pickLodList(part.lod, tolerance) : null;
|
||||
scanned += list ? list.length : part.coords.length / 2;
|
||||
}
|
||||
|
||||
// 고른 단 = 「무게 ≥ 오차」 정점을 모두 담음(여러 오차)
|
||||
let supersetOk = true;
|
||||
for (const tol of [1e-2, 1e-3, 3e-4, 1e-4, 2e-5, 5e-6, 1e-6, 1e-7])
|
||||
for (const feature of indexed.features)
|
||||
for (const part of feature.parts) {
|
||||
if (!part.lod) continue;
|
||||
const list = prep.pickLodList(part.lod, tol);
|
||||
if (!list) continue;
|
||||
const inList = new Set(list);
|
||||
for (let v = 0; v < part.weights.length; v += 1)
|
||||
if (part.weights[v] >= tol && !inList.has(v)) supersetOk = false;
|
||||
for (let k = 1; k < list.length; k += 1) if (list[k] <= list[k - 1]) supersetOk = false;
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
lodParts,
|
||||
shortParts,
|
||||
closedParts: indexed.features.filter((f) => f.parts.some((p) => p.closed)).length,
|
||||
cases,
|
||||
scannedRatio: scanned / all,
|
||||
supersetOk,
|
||||
noLodFinest: prep.pickLodList(indexed.features[0].parts[0].lod, 1e-9) === null,
|
||||
}),
|
||||
);
|
||||
@@ -1,5 +1,5 @@
|
||||
"""D02 기준 지정(PLAN 529-3 · 560-2) — 경위도 ↔ TM 변환 · 전체 두 점 정본 API · 옛 연도 행 접기
|
||||
· 연도 잇기(D03 [저장]이 씀).
|
||||
"""D02 기준 지정(PLAN 529-3 · 560-2 · 579-1) — 경위도 ↔ TM 변환 · 시점 · 종점 목록 정본 API ·
|
||||
옛 한 점 꼴 감쌈 · 옛 연도 행 접기 · 저장된 좌표계 그대로.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_529_3_plan_points.py -q
|
||||
"""
|
||||
@@ -7,7 +7,7 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common_util.common_util_auth import require_company
|
||||
@@ -80,32 +80,53 @@ def api(monkeypatch):
|
||||
return client
|
||||
|
||||
|
||||
def test_put_two_points(api) -> None:
|
||||
got = api.put(URL, json={"start": A, "end": C})
|
||||
def test_put_lists(api) -> None:
|
||||
got = api.put(URL, json={"starts": [A, B], "ends": [C]})
|
||||
assert got.status_code == 200
|
||||
data = got.json()
|
||||
assert data["epsg"] == 5186
|
||||
assert (data["start"]["x"], data["start"]["y"]) == pytest.approx(
|
||||
assert data["epsg"] == 5186 and len(data["starts"]) == 2 and len(data["ends"]) == 1
|
||||
assert (data["starts"][0]["x"], data["starts"][0]["y"]) == pytest.approx(
|
||||
lonlat_to_tm(127.10, 37.50, 5186)
|
||||
)
|
||||
assert abs(data["end"]["lon"] - C["lon"]) < 1e-8 and abs(data["end"]["lat"] - C["lat"]) < 1e-8
|
||||
end = data["ends"][0]
|
||||
assert abs(end["lon"] - C["lon"]) < 1e-8 and abs(end["lat"] - C["lat"]) < 1e-8
|
||||
stored = json.loads(api.state["project"]["plan_base_json"])
|
||||
assert set(stored) == {"epsg", "start", "end"} and set(stored["start"]) == {"x", "y"}
|
||||
assert set(stored) == {"epsg", "starts", "ends"} and set(stored["starts"][1]) == {"x", "y"}
|
||||
assert api.state["rows"] == {} # 연도 행은 D02 가 안 만듦
|
||||
|
||||
|
||||
def test_put_one_point_and_empty(api) -> None:
|
||||
data = api.put(URL, json={"start": A}).json()
|
||||
assert data["start"] and data["end"] is None and data["epsg"] == 5186
|
||||
def test_put_partial_and_empty(api) -> None:
|
||||
data = api.put(URL, json={"starts": [A]}).json()
|
||||
assert len(data["starts"]) == 1 and data["ends"] == [] and data["epsg"] == 5186
|
||||
data = api.put(URL, json={}).json()
|
||||
assert data == {"status": "success", "epsg": None, "start": None, "end": None}
|
||||
assert data == {"status": "success", "epsg": None, "starts": [], "ends": []}
|
||||
assert api.put(URL, json={"starts": [A] * 21}).status_code == 422 # 상한 20
|
||||
|
||||
|
||||
def test_put_keeps_stored_epsg(api) -> None:
|
||||
api.put(URL, json={"starts": [A], "ends": [C]})
|
||||
far = {"lon": 128.9, "lat": 37.5} # 평균이 128° 를 넘어도 저장된 5186 그대로
|
||||
data = api.put(URL, json={"starts": [A], "ends": [far, far]}).json()
|
||||
assert data["epsg"] == 5186
|
||||
|
||||
|
||||
def test_get_wraps_old_single_points(api) -> None:
|
||||
a, c = (lonlat_to_tm(p["lon"], p["lat"], 5186) for p in (A, C))
|
||||
old = {"epsg": 5186, "start": {"x": a[0], "y": a[1]}, "end": {"x": c[0], "y": c[1]}}
|
||||
api.state["project"]["plan_base_json"] = json.dumps(old)
|
||||
data = api.get(URL).json()
|
||||
assert [p["x"] for p in data["starts"]] == pytest.approx([a[0]])
|
||||
assert [p["x"] for p in data["ends"]] == pytest.approx([c[0]])
|
||||
old["end"] = None
|
||||
api.state["project"]["plan_base_json"] = json.dumps(old)
|
||||
assert api.get(URL).json()["ends"] == []
|
||||
|
||||
|
||||
def test_put_forest_only(api) -> None:
|
||||
api.state["session"] = {"role": "USER", "email": "lee@company.com", "company_id": 1}
|
||||
assert api.put(URL, json={"start": A, "end": B}).status_code == 403
|
||||
assert api.put(URL, json={"starts": [A], "ends": [B]}).status_code == 403
|
||||
api.state["session"] = {"role": "SYSTEM_ADMIN", "email": "admin@x.com", "company_id": None}
|
||||
assert api.put(URL, json={"start": A, "end": B}).status_code == 200
|
||||
assert api.put(URL, json={"starts": [A], "ends": [B]}).status_code == 200
|
||||
|
||||
|
||||
def test_get_folds_old_year_rows(api) -> None:
|
||||
@@ -119,19 +140,8 @@ def test_get_folds_old_year_rows(api) -> None:
|
||||
}
|
||||
data = api.get(URL).json()
|
||||
assert data["epsg"] == epsg
|
||||
assert (data["start"]["x"], data["end"]["x"]) == pytest.approx((a[0], c[0]))
|
||||
assert (data["starts"][0]["x"], data["ends"][0]["x"]) == pytest.approx((a[0], c[0]))
|
||||
|
||||
|
||||
def test_get_empty_new_project(api) -> None:
|
||||
assert api.get(URL).json() == {"status": "success", "epsg": None, "start": None, "end": None}
|
||||
|
||||
|
||||
def test_chain_years_for_d03() -> None:
|
||||
items = [
|
||||
router_mod.YearPoints(year=2026, start=A, end=B),
|
||||
router_mod.YearPoints(year=2027, start=A, end=C),
|
||||
]
|
||||
rows = router_mod.chain_years(2026, 2027, items)
|
||||
assert rows[1]["start"].lon == B["lon"] # 시점 = 직전 종점
|
||||
with pytest.raises(HTTPException):
|
||||
router_mod.chain_years(2026, 2027, items[:1])
|
||||
assert api.get(URL).json() == {"status": "success", "epsg": None, "starts": [], "ends": []}
|
||||
|
||||
@@ -184,7 +184,7 @@ class _Db:
|
||||
def client(monkeypatch):
|
||||
db = _Db()
|
||||
monkeypatch.setattr(net, "get_db_pool", lambda: db)
|
||||
base = {"epsg": 5186, "start": {"x": 0.0, "y": 0.0}, "end": {"x": 90.0, "y": 180.0}}
|
||||
base = {"epsg": 5186, "starts": [{"x": 0.0, "y": 0.0}], "ends": [{"x": 90.0, "y": 180.0}]}
|
||||
|
||||
async def load_base(project_id):
|
||||
return base
|
||||
@@ -214,7 +214,7 @@ def test_get_saved_legacy_or_empty(client) -> None:
|
||||
client.db.answers["FROM plan_networks"] = [{"network_json": json.dumps(_network())}]
|
||||
got = client.get(URL).json()
|
||||
assert got["legacy"] is False and len(got["derived"]["pieces"]) == 3
|
||||
assert got["base"]["end"] == {"x": 90.0, "y": 180.0} and got["year_start"] == 2026
|
||||
assert got["base"]["ends"] == [{"x": 90.0, "y": 180.0}] and got["year_start"] == 2026
|
||||
|
||||
|
||||
def test_put_stores_network_pieces_years(client) -> None:
|
||||
|
||||
@@ -31,8 +31,8 @@ class _Pool:
|
||||
def client(tmp_path, monkeypatch):
|
||||
plan = {
|
||||
"epsg": 5186,
|
||||
"start": {"lon": 0, "lat": 0, "x": P0[0], "y": P0[1]},
|
||||
"end": {"lon": 0, "lat": 0, "x": P2[0], "y": P2[1]},
|
||||
"starts": [{"lon": 0, "lat": 0, "x": P0[0], "y": P0[1]}],
|
||||
"ends": [{"lon": 0, "lat": 0, "x": P2[0], "y": P2[1]}],
|
||||
}
|
||||
|
||||
async def load_base(project_id):
|
||||
@@ -61,7 +61,7 @@ def test_extent_uses_whole_ends(client) -> None:
|
||||
extra = client.get(f"{URL}/extent", params={"extra": json.dumps([[2000, 0]])}).json()
|
||||
assert extra["box_m"][2] == 2000 + extra["margin_m"]
|
||||
assert client.get(f"{URL}/extent", params={"extra": "[1]"}).status_code == 400
|
||||
client.plan["end"] = None # 종점 빔 — 1단계로
|
||||
client.plan["ends"] = [] # 종점 빔 — 1단계로
|
||||
assert client.get(f"{URL}/extent").status_code == 400
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""D03 손 긋기 꼴 다루기(PLAN 562-3 · 563-1) — TS `logic/D03_PlanAnalysis_Plan.ts`.
|
||||
"""D03 손 긋기 꼴 다루기(PLAN 562-3 · 563-1 · 580) — TS `logic/D03_PlanAnalysis_Plan.ts`.
|
||||
|
||||
조각이 선을 빈틈없이 덮음 · 마크 놓기 · 지우기 · 갈래 차례 밀기 · 갈래 붙은 점 못 뺌 · 서버 꼴 읽기 ·
|
||||
화면이 보내는 꼴(선 · 마크 · 조각)을 서버 셈(`D03_PlanAnalysis_Network.derive`)이 그대로 받음.
|
||||
조각이 선을 빈틈없이 덮음 · 마크 놓기 · 지우기 · 갈래 차례 밀기 · 갈래 붙은 점 못 뺌 ·
|
||||
서버 꼴 읽기 · 화면 꼴(선 · 마크 · 조각)을 서버 셈(`derive`)이 그대로 받음 ·
|
||||
580 = 종점에서 그은 선 뒤집어 잇기 · 갈래로 붙음 · 꺾임점 옮김 · 지움(마크 비율) ·
|
||||
선 지움 · 방향 바꿈 · 선 통째 연도 · 마크 옮김 · 선 이름 거울(D04 `line_names`).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_530_3_plan_logic.py -q
|
||||
"""
|
||||
@@ -19,6 +21,7 @@ if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Network as server # noqa: E402
|
||||
from D04_PlanConfirm.D04_PlanConfirm_Repository import line_names # noqa: E402
|
||||
|
||||
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Plan.ts"
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
@@ -27,7 +30,8 @@ _RUNNER = """
|
||||
import { writeFileSync } from "node:fs";
|
||||
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
|
||||
const Y = 2026;
|
||||
let n = plan.emptyNetwork([0, 0]);
|
||||
const empty = plan.emptyNetwork();
|
||||
let n = plan.newLine(empty, [0, 0]);
|
||||
n = plan.appendVertex(n, 0, [100, 0]);
|
||||
n = plan.appendVertex(n, 0, [100, 100]);
|
||||
n = plan.normalizePieces(n, Y);
|
||||
@@ -52,10 +56,57 @@ const read = plan.fromServer(
|
||||
[{ id: "p9", line: "main", from_m: 0, to_m: 50, year: 2028 }],
|
||||
Y,
|
||||
);
|
||||
// 580 — 본선(0,0)→(100,0) 반쯤 · 종점(200,0)에서 거꾸로 그은 선이 본선 꼬리에 닿음 → 한 선
|
||||
let j = plan.normalizePieces(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), Y);
|
||||
j = plan.newLine(j, [200, 0]);
|
||||
j = plan.appendVertex(j, 1, [150, 50]);
|
||||
j = plan.appendVertex(j, 1, [100, 0]);
|
||||
j = plan.normalizePieces(j, 2027);
|
||||
const joined = plan.normalizePieces(plan.attachHead(plan.reverseLine(j, 1), 1), Y);
|
||||
const sameYear = plan.normalizePieces(
|
||||
plan.attachHead(plan.reverseLine(plan.setLineYear(j, j.lines[1].id, Y), 1), 1),
|
||||
Y,
|
||||
);
|
||||
// 종점에서 그은 선이 본선 꺾임점에 닿음 → 그 점의 갈래(선 차례 = 부모 뒤)
|
||||
let b = plan.normalizePieces(
|
||||
plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [200, 0]),
|
||||
Y,
|
||||
);
|
||||
b = plan.newLine(b, [100, 80]);
|
||||
b = plan.appendVertex(b, 1, [100, 0]);
|
||||
const asBranch = plan.attachHead(plan.reverseLine(b, 1), 1);
|
||||
// 꺾임점 옮김 · 지움 — 마크 비율 · 붙은 갈래 첫 점
|
||||
let m = plan.normalizePieces(
|
||||
plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [100, 100]),
|
||||
Y,
|
||||
);
|
||||
m = plan.normalizePieces(plan.splitPiece(m, "main", 150), Y);
|
||||
m = plan.normalizePieces(plan.appendVertex(plan.addBranch(m, 0, 1), 1, [160, 0]), Y);
|
||||
const movedV = plan.normalizePieces(plan.moveVertex(m, 0, 1, [100, -100]), Y);
|
||||
let r = plan.normalizePieces(
|
||||
plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [100, 100]),
|
||||
Y,
|
||||
);
|
||||
r = plan.normalizePieces(plan.splitPiece(r, "main", 150), Y);
|
||||
const removedV = plan.normalizePieces(plan.removeVertex(r, 0, 1), Y);
|
||||
const removeBlocked = plan.removeVertex(m, 0, 1);
|
||||
const lineBlocked = plan.removeLine(m, 0);
|
||||
const lineGone = plan.normalizePieces(plan.removeLine(m, 1), Y);
|
||||
const reversed = plan.normalizePieces(plan.reverseLine(m, 0), Y);
|
||||
const branchFlip = plan.reverseLine(m, 1);
|
||||
const lineYear = plan.setLineYear(m, "main", 2029);
|
||||
const markMoved = plan.normalizePieces(plan.moveMark(m, 1, 120), Y);
|
||||
const markClamp = plan.normalizePieces(plan.moveMark(m, 1, 500), Y);
|
||||
const names = [
|
||||
plan.lineNames([{ branch: null }]),
|
||||
plan.lineNames([{ branch: null }, { branch: null }, { branch: { line: "main", vertex: 1 } }]),
|
||||
plan.lineNames([{ branch: null }, { branch: { line: "main", vertex: 1 } }]),
|
||||
];
|
||||
writeFileSync(process.argv[2], JSON.stringify({
|
||||
one, split, tooClose, branched, inserted, blocked, dropped, guard, removed, grown, read,
|
||||
marks: plan.marksOf(branched),
|
||||
reached: plan.reachedEnd(n, [100, 100]),
|
||||
empty, joined, sameYear, asBranch, m, movedV, removedV, removeBlocked, lineBlocked, lineGone,
|
||||
reversed, branchFlip, lineYear, markMoved, markClamp, names,
|
||||
}));
|
||||
"""
|
||||
|
||||
@@ -111,7 +162,7 @@ def test_pieces_cover_and_split(tmp_path: Path) -> None:
|
||||
assert got["tooClose"] == got["split"] # 마크 1 m 안 = 안 놓음
|
||||
assert _spans(got["removed"]) == [(0, 200, 2026)] # 마크 지움 = 앞 조각 연도
|
||||
assert _spans(got["grown"]) == [(0, 50, 2026), (50, 250, 2027)] # 선이 늘면 끝 조각이 늚
|
||||
assert got["reached"] is True
|
||||
assert got["empty"] == {"lines": [], "pieces": []} # 빈 꼴 = 선 없음(시점 · 종점에서 시작)
|
||||
|
||||
|
||||
def test_branch_order_and_guard(tmp_path: Path) -> None:
|
||||
@@ -144,3 +195,71 @@ def test_server_accepts_screen_body(tmp_path: Path) -> None:
|
||||
("main", 50.0, 200.0, 2027),
|
||||
("branch-1", 0.0, 60.0, 2026),
|
||||
]
|
||||
|
||||
|
||||
def test_reversed_stroke_joins_or_branches(tmp_path: Path) -> None:
|
||||
got = _run_ts(tmp_path)
|
||||
joined = got["joined"]
|
||||
# 종점에서 그은 선 = 뒤집혀 본선 꼬리에 이어 한 선 · No.0 = 시점 쪽 · 다른 해면 이음 자리 마크
|
||||
assert len(joined["lines"]) == 1
|
||||
assert joined["lines"][0]["vertices"] == [[0, 0], [100, 0], [150, 50], [200, 0]]
|
||||
total = 100 + 2 * (50**2 * 2) ** 0.5
|
||||
spans = _spans(joined)
|
||||
assert spans[0] == (0, 100, 2026) and spans[1][0] == 100 and spans[1][2] == 2027
|
||||
assert abs(spans[1][1] - total) < 1e-3
|
||||
assert len(got["sameYear"]["pieces"]) == 1 # 같은 해면 이음 자리 마크 없음
|
||||
branch = got["asBranch"]["lines"][1]
|
||||
assert branch["branch"] == {"line": "main", "vertex": 1}
|
||||
assert branch["vertices"] == [[100, 0], [100, 80]]
|
||||
body = {**got["asBranch"], "marks": [], "pieces": [], "epsg": 5186}
|
||||
server.derive(body, 2026) # 서버가 그대로 받음(갈래 첫 점 = 부모 점)
|
||||
body = {**joined, "marks": [], "epsg": 5186}
|
||||
body["marks"] = [
|
||||
{"line": p["line"], "at_m": p["from_m"]} for p in joined["pieces"] if p["from_m"] > 0
|
||||
]
|
||||
assert len(server.derive(body, 2026)["pieces"]) == 2
|
||||
|
||||
|
||||
def test_vertex_move_remove_keeps_marks(tmp_path: Path) -> None:
|
||||
got = _run_ts(tmp_path)
|
||||
# 본선 (0,0)-(100,0)-(100,100) · 마크 150 m(둘째 구간 반) · 갈래 = 꺾임점 1 에서 (160,0)
|
||||
moved = got["movedV"]
|
||||
assert moved["lines"][0]["vertices"][1] == [100, -100]
|
||||
assert moved["lines"][1]["vertices"][0] == [100, -100] # 붙은 갈래 첫 점 같이
|
||||
first = (100**2 + 100**2) ** 0.5
|
||||
mark = [p for p in moved["pieces"] if p["line"] == "main"][1]["from_m"]
|
||||
assert abs(mark - (first + 100)) < 1e-3 # 둘째 구간(길이 200) 반
|
||||
removed = got["removedV"]
|
||||
assert removed["lines"][0]["vertices"] == [[0, 0], [100, 100]]
|
||||
share = 0.75 * (2 * 100**2) ** 0.5 # 합친 구간(200 m) 안 150 m = 3/4
|
||||
assert abs(_spans(removed)[1][0] - share) < 1e-3
|
||||
assert "갈래가 붙은 점" in got["removeBlocked"]
|
||||
assert "갈래가 붙은 선" in got["lineBlocked"]
|
||||
assert [line["id"] for line in got["lineGone"]["lines"]] == ["main"]
|
||||
assert all(p["line"] == "main" for p in got["lineGone"]["pieces"])
|
||||
assert "갈래는 방향을" in got["branchFlip"]
|
||||
|
||||
|
||||
def test_reverse_year_mark_move(tmp_path: Path) -> None:
|
||||
got = _run_ts(tmp_path)
|
||||
rev = got["reversed"]
|
||||
assert rev["lines"][0]["vertices"] == [[100, 100], [100, 0], [0, 0]]
|
||||
assert rev["lines"][1]["branch"] == {"line": "main", "vertex": 1} # 3 − 1 − 1
|
||||
assert _spans(rev) == [(0, 50, 2026), (50, 200, 2026)]
|
||||
assert {p["year"] for p in got["lineYear"]["pieces"] if p["line"] == "main"} == {2029}
|
||||
assert _spans(got["markMoved"]) == [(0, 120, 2026), (120, 200, 2026)]
|
||||
assert _spans(got["markClamp"]) == [(0, 199, 2026), (199, 200, 2026)] # 끝에서 1 m 남김
|
||||
body = {**rev, "marks": [{"line": "main", "at_m": 50}], "epsg": 5186}
|
||||
server.derive(body, 2026)
|
||||
|
||||
|
||||
def test_line_names_mirror(tmp_path: Path) -> None:
|
||||
got = _run_ts(tmp_path)
|
||||
cases = [
|
||||
[{"branch": None}],
|
||||
[{"branch": None}, {"branch": None}, {"branch": {"line": "main", "vertex": 1}}],
|
||||
[{"branch": None}, {"branch": {"line": "main", "vertex": 1}}],
|
||||
]
|
||||
assert got["names"] == [line_names(c) for c in cases]
|
||||
assert got["names"][1] == ["본선 1", "본선 2", "갈래 1"] # 시점 둘 = 둘째도 본선
|
||||
assert got["names"][2] == ["본선", "갈래 1"] # 옛 꼴 이름 그대로
|
||||
|
||||
@@ -47,7 +47,8 @@ def test_d03_parts():
|
||||
assert '"rotate-ccw", "rotate-cw"' in tools
|
||||
for b05_only in ("footprint", "station-snap", "contour-minor"):
|
||||
assert f'"{b05_only}"' not in tools, b05_only
|
||||
assert "map.stations" in draw and "map.slopes" in draw
|
||||
paint = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts") # 580 — 그림 나눔
|
||||
assert "map.stations" in paint and "map.slopes" in paint
|
||||
# 569-2 · 581-1 — 바탕 겹 단추(공용 지도 도구줄 한 줄 · 묶음 셋) · 처음 켬 셋 · 취향 기억
|
||||
defs = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts")
|
||||
assert "createMapTools({" in layers and "createIconButton({" in layers
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""582 등고선 줌 단계 색인 · 글 폭 기억 — 그림 그대로(D03 먼저).
|
||||
|
||||
- 색인 붙인 레이어 = 안 붙인 레이어와 그리기 명령(차례 · 좌표)이 똑같음 — 여러 줌 · 자리 ·
|
||||
닫힌 고리 · 짧은 조각(색인 없음) · 간격 거름(25 m)
|
||||
- 고른 단은 「무게 ≥ 허용 오차」 정점을 모두 담고 원래 차례 · 아주 확대하면 색인 없이 전부 훑음
|
||||
- 다 줌아웃에서 훑는 정점이 줄어듦
|
||||
- 글 폭 기억 = 같은 글 · 같은 자리 · 두 번째부터 재지 않음
|
||||
- 색인 · 바탕 캔버스는 D03 만 켬(B04 · B05 는 안 부름)
|
||||
|
||||
Node 헬퍼(`helper_582_contour_lod.cjs`)가 진짜 TS 를 가짜 캔버스로 돌린다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = Path(HERE).parents[1]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_582_contour_lod.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_조각_갈래가_다_있음(r):
|
||||
assert r["lodParts"] > 20
|
||||
assert r["shortParts"] > 10
|
||||
assert r["closedParts"] > 10
|
||||
|
||||
|
||||
def test_색인으로_그린_명령이_지금과_같음(r):
|
||||
assert len(r["cases"]) == 27
|
||||
for case in r["cases"]:
|
||||
assert case["same"], case
|
||||
# 줌 · 자리마다 그린 정점 수가 실제로 달라짐(거름이 일함)
|
||||
assert len({c["vertices"] for c in r["cases"]}) > 15
|
||||
assert all(c["vertices"] > 0 for c in r["cases"] if c["scale"] <= 13)
|
||||
|
||||
|
||||
def test_고른_단은_남는_정점을_모두_담음(r):
|
||||
assert r["supersetOk"]
|
||||
assert r["noLodFinest"]
|
||||
|
||||
|
||||
def test_다_줌아웃_훑기가_줄어듦(r):
|
||||
assert r["scannedRatio"] < 0.5
|
||||
|
||||
|
||||
def test_글_폭_기억은_같은_글(r):
|
||||
for case in r["cases"]:
|
||||
assert case["labelsSame"], case
|
||||
assert case["labelsRepeatSame"], case
|
||||
assert case["labelMeasuredCached"] == 0
|
||||
assert any(c["labelMeasuredPlain"] > 0 for c in r["cases"])
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_D03_만_켬():
|
||||
layers = _read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
|
||||
assert "indexLayerLod(prepare(collection" in layers
|
||||
# 581-1 합침 — 겹 모두 바탕 캔버스
|
||||
assert layers.count("base: true") == layers.count("draw: (context, map)") == 12
|
||||
plan_map = _read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
|
||||
assert "requestAnimationFrame(flush)" in plan_map
|
||||
assert "drawTop();" in plan_map
|
||||
users = [
|
||||
p.relative_to(ROOT).as_posix()
|
||||
for folder in ("B04_PreProcess", "B05_Profile", "B06_Section", "A00_Common")
|
||||
for p in (ROOT / folder).rglob("*.ts")
|
||||
if "indexLayerLod(" in p.read_text(encoding="utf-8")
|
||||
]
|
||||
assert users == ["B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare.ts"]
|
||||
@@ -118,13 +118,16 @@ export const ui_locales_de = {
|
||||
DE_Link_NoRoad: ["우리 회사 임도 프로젝트가 없습니다.", "No road project in our company."],
|
||||
|
||||
/* --- D02 기준 지정(529-3) --- */
|
||||
D02_Points_Title: ["전체 시점 · 종점", "Overall Start · End"],
|
||||
D02_Points_Title: ["시점 · 종점", "Starts · Ends"],
|
||||
D02_Points_Hint: [
|
||||
"📍 로 고른 지점을 지도 누름으로 놓고 · 표지를 끌거나 경도 · 위도를 넣습니다. 연도 나누기는 노선분석에서.",
|
||||
"Pick a point with 📍 and click the map, drag markers, or type lon · lat. Split by year in analysis.",
|
||||
],
|
||||
D02_Start: ["시점", "start"],
|
||||
D02_End: ["종점", "end"],
|
||||
D02_Add_Start: ["시점", "Start"],
|
||||
D02_Add_End: ["종점", "End"],
|
||||
D02_Remove_Point: ["이 지점 지움", "Remove this point"],
|
||||
D02_Lon: ["경도", "Lon"],
|
||||
D02_Lat: ["위도", "Lat"],
|
||||
D02_Pick_Point: ["지도에 놓을 지점 고르기", "Pick the point to place on the map"],
|
||||
|
||||
Reference in New Issue
Block a user