Merge remote-tracking branch 'origin/dev' into sub_laptop_6

This commit is contained in:
2026-10-30 15:30:13 +09:00
42 changed files with 3814 additions and 831 deletions
+6 -5
View File
@@ -138,17 +138,18 @@ export const KEY_ACTIONS: readonly KeyAction[] = [
A("plan.redo", "plan", ["Ctrl+Shift+Z", "Ctrl+Y"], ["다시", "Redo"]),
A("plan.drawFinish", "plan", ["Enter"], ["그리기 끝", "Finish drawing"]),
A("plan.drawBack", "plan", ["Backspace"], ["그리기 마지막 점 빼기", "Drawing: remove last point"]),
A("plan.delete", "plan", ["Delete"], ["고른 것 지움", "Delete selection"]),
A("plan.delete", "plan", ["Delete"], ["고른 노드 · 선 · 구역 · 전체 · 마커 지움", "Delete picked node · segment · region · all · marker"]),
A("plan.escape", "plan", ["Escape"], ["그리기 취소 · 고름 풀기 · 재기 끝", "Cancel drawing · clear selection · end measure"], F),
A("plan.zoom", "plan", ["휠"], ["커서 자리 확대 · 축소", "Zoom at cursor"], F),
A("plan.rotate", "plan", ["Ctrl+휠"], ["지도 돌림", "Rotate map"], F),
A("plan.pan", "plan", [PAN], ["화면 옮김", "Pan"], F),
A("plan.vertex", "plan", ["고른 선 누름", "꼭짓점 끌기"], ["꼭짓점 넣고 끌기 · 옮김", "Insert · drag vertex"], F),
A("plan.unvertex", "plan", ["꼭짓점 · 마커 오른쪽 누름"], ["지움", "Delete"], F),
A("plan.select", "plan", ["누름 · 다시 누름"], ["노드 → 선 → 구역 → 전체 고름", "Pick node → segment → region → all"], F),
A("plan.vertex", "plan", ["고른 선 끌기", "노드 끌기"], ["꼭짓점 넣고 끌기 · 노드 옮김(지점 같이)", "Insert · drag vertex (point follows)"], F),
A("plan.unvertex", "plan", ["노드 · 마커 오른쪽 누름"], ["메뉴 [노드 지움] · [마커 지움]", "Menu: delete node · marker"], F),
A("plan.measure", "plan", ["재기: 누름 · 누름"], ["시점 · 종점", "Start · end"], F),
A("plan.menu", "plan", ["오른쪽 누름"], ["지도 메뉴 — 긋기 시작 · 지점 놓기 · 갈래", "Map menu — draw · place point · branch"], F),
A("plan.point", "plan", ["지점 핀 끌기"], ["지점 옮김(그 자리 노드 같이)", "Move point (with its node)"], F),
A("plan.menu", "plan", ["오른쪽 누름"], ["지도 메뉴 — 긋기 · 지점 놓기 · 노드 지움 · 연도 구역 나눔", "Map menu — draw · place point · delete node · split year"], F),
A("plan.point", "plan", ["지점 핀 끌기"], ["지점 옮김(그 자리 노드 같이 · 노드 12 px 안 = 붙음)", "Move point (with its node · snaps within 12 px)"], F),
A("b04.zoom", "b04", ["휠"], ["지도 · 3D 확대 · 축소", "Map · 3D zoom"], F),
A("b04.pan", "b04", [PAN], ["지도 · 3D 옮김", "Map · 3D pan"], F),
@@ -47,6 +47,8 @@ export interface RouteEditNavigationParams {
leftPan?: boolean;
/** 왼쪽이 옮기기 버튼일 때 페이지가 그 누름을 잡았나(노드 · 마커) — 안 주면 포인터를 잡은 겹이 있으면 잡음 */
claimed?: () => boolean;
/** 배율 하한 — 안 주면 0.5(B05 그대로). 예정노선 지도만 한반도까지 빼려고 낮은 값을 줌(600-3). */
minScale?: () => number;
/** Ctrl+휠 = 돌리기(390-3) — 안 주면 Ctrl+휠도 확대(추천 지도). 손가락 · 트랙패드 벌림은 늘 확대. */
rotateWheel?: (sign: 1 | -1) => void;
draw: () => void;
@@ -81,7 +83,10 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
MAX_SCALE_FALLBACK,
);
const factor = wheelZoomsIn(event) ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR;
const scale = Math.min(maxScale, Math.max(MIN_SCALE, view.scale * factor));
const scale = Math.min(
maxScale,
Math.max(params.minScale?.() ?? MIN_SCALE, view.scale * factor),
);
const ratio = scale / view.scale;
const rect = canvas.getBoundingClientRect();
// 커서 자리를 **화면 중심 기준**으로 잡는다 — 그래야 그 지점이 제자리에 남는다.
+9 -2
View File
@@ -5,6 +5,7 @@
* · 사업연도 끝 · 임도 종류 · 측점은 안 받음(끝 연도 = D03 [저장] · 나머지 = 서버 기본) · 설계속도 없음(591-1).
* 기준점(591-1 · 요구 452 · 453) = 카카오 지도(공용 editMap · 마우스 설정 따름) 누름 · 끌기로 한 점 →
* GET /api/map/address(VWorld · 출처 표기) → 주소 글 · 사업 지역 「시군구 읍면」 채움(손으로 고친 뒤엔 안 덮음)
* 지도 머리 [위성] 오른쪽 = 주소 · 장소 찾기(596-5 · 예정노선 위치 칸과 같은 `createPlaceFind` · 서버 VWorld) → 고름 = 그 자리로 기준점.
* [등록] = POST /api/d01/project(site = 경위도 · 주소는 서버가 다시 찾음) → 현재 프로젝트 · D03 으로.
* ========================================================================== */
@@ -20,6 +21,7 @@ import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { createGroupProject } from "../A00_Common/group_workflow";
import { navigateTo } from "../A00_Common/router";
import { createNameComposer } from "../B02_ProjRegister/B02_ProjRegister_UI_Name";
import { createPlaceFind } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Site";
const KOREA_CENTER: [number, number] = [127.8, 36.3];
const DIGITS = 6; // 경위도 소수 자리(약 0.1 m)
@@ -85,12 +87,17 @@ function sitePicker(onRegion: (region: string) => void): {
}
draw();
// 찾기 = 칸은 머리 줄([위성] 오른쪽 · 좁으면 줄바꿈) · 알림 · 후보 줄은 머리 아래
const find = createPlaceFind((hit) => void moveTo(hit.lon, hit.lat));
const field = find.search.parentElement!;
field.style.flex = "1 1 12rem";
const head = document.createElement("div");
head.className = "ui-general-block__actions";
head.append(createText(t("DE_Reg_Site"), "muted", 14), typePick.root);
head.style.flexWrap = "wrap";
head.append(createText(t("DE_Reg_Site"), "muted", 14), typePick.root, field);
const root = document.createElement("div");
root.dataset.d01Site = "";
root.append(head, map.root, where);
root.append(head, find.root, map.root, where);
return { root, value: () => site };
}
+20 -16
View File
@@ -30,23 +30,24 @@ export interface PlanBaseView {
site: { lon: number; lat: number; address: string | null } | null;
}
/** 진행(595-3) — 끊김 = running 거짓 · percent < 100 · message 없음 · interrupted(서버가 다시 켜짐) ·
* 받은 적 없음 = percent 100 · message = 「n/5 …」 단계 글. */
export interface PlanJob {
running: boolean;
percent: number;
message: string | null;
error: string | null;
/** 서버 다시 켜짐 등으로 끊김(595-3). */
interrupted?: boolean;
}
/** 받기 시작 답(595-2) — 받음 · 멂(받은 범위에서 5 km 넘음 → 묻고 `reset`) · 다른 TM 띠(노선 지움 확인 뒤 `reset`). */
/** 받기 답(595-2) — 시작 · 멀다(받은 범위에서 5 km 넘음) · 다른 TM 띠 — 둘은 확인 뒤 `reset`. */
export type TerrainStart =
| (PlanJob & { status?: "accepted" })
| ({ status: "accepted" } & PlanJob)
| { status: "far"; distance_m: number; message: string }
| { status: "band"; epsg: number; center_epsg: number; message: string };
/** 위치 칸 기준점(캐시 · [저장] 전) — 서버가 둘레 ±1 km 를 더 덮음 · `reset` = 옛 받은 범위 버림. */
export interface TerrainCenter {
/** 위치 칸 기준점(경위도 · 캐시) · 새로 받기(옛 받은 범위 버림). */
export interface TerrainAim {
center?: { lon: number; lat: number } | null;
reset?: boolean;
}
@@ -62,6 +63,8 @@ export interface TerrainExtent {
covered: boolean;
sheets: Array<{ no: string; ring: Array<[number, number]>; stored: boolean }>;
cells: number;
/** 받기 칸 상한(넘으면 받기 413). */
max_cells: number;
}
/** 그은 선 서버 꼴(562-2) — 마크 = 조각 경계 · 조각 없는 구역 = 연도 없음. */
@@ -110,12 +113,12 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const base = (projectId: string): string => `/projects/${projectId}/plan`;
/** 받기 전 범위 — `extra` = 더 덮을 점(그은 꺾임점). */
/** 받기 전 범위 — `extra` = 더 덮을 점(그은 꺾임점) · `center` = 위치 칸 기준점(둘레 ±1 km). */
export const fetchTerrainExtent = (
projectId: string,
marginM: number | null,
extra: Vertex[],
center: TerrainCenter["center"] = null,
center: { lon: number; lat: number } | null = null,
): Promise<TerrainExtent> => {
const query = new URLSearchParams();
if (marginM !== null) query.set("margin_m", String(marginM));
@@ -127,15 +130,16 @@ export const fetchTerrainExtent = (
return request(`${base(projectId)}/terrain/extent?${query}`);
};
/** 받기 — 칸 상한 넘음 = 오류(413 글). */
export const startTerrain = (
projectId: string,
marginM: number | null,
extra: Vertex[],
where: TerrainCenter = {},
aim: TerrainAim = {},
): Promise<TerrainStart> =>
request(`${base(projectId)}/terrain`, {
method: "POST",
body: JSON.stringify({ margin_m: marginM, extra, ...where }),
body: JSON.stringify({ margin_m: marginM, extra, ...aim }),
});
export const fetchPlanProgress = (projectId: string): Promise<{ terrain: PlanJob }> =>
@@ -175,13 +179,14 @@ export async function fetchPlanSurface(projectId: string): Promise<PlanSurface |
return { modelId: model.id, grid: parseDtmGrid(await fetchCachedBytes(projectId, url)) };
}
/** 지도 겹 그림(569-1) — 작업 좌표계 m 상자(칸 가장자리) · data URL(위 = 북) · 단계 색 · 넓이 비율. */
/** 지도 겹 그림(569-1) — 작업 좌표계 m 상자(칸 가장자리) · data URL(위 = 북). */
export interface PlanRaster {
box_m: [number, number, number, number];
/** 경위도 네 귀퉁이(왼쪽 아래 · 오른쪽 아래 · 오른쪽 위 · 왼쪽 위) — GL 바탕(586-1). */
box?: Array<[number, number]>;
image: string;
bands: Array<{ min_deg: number; max_deg: number; color: string; ratio: number }>;
/** 급경사만(603) — `counts[v]` = 기울기 % 내림 v(0~250) 칸 수 · 그림 = 회색 칸 값(255 = 빈 칸). */
counts?: number[];
}
export const fetchPlanCadastral = (projectId: string): Promise<GeoJsonCollection> =>
@@ -246,18 +251,17 @@ export const fetchPlanZeroOrder = (projectId: string): Promise<PlanZeroOrder> =>
export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> =>
request(`${base(projectId)}/layers/bands`);
/** 올린 임도망도(584-3) — 받은 범위로 자른 선 + 가닥 · 길이 m · 원본 선 수 · 좌표계 · 올린 파일. */
/** 올린 임도망도(584-3 · 595-4) — 기준점 ±5 km ∪ 받은 지형으로 자른 선 + 가닥 · 길이 m · 원본 선 수 ·
* 좌표계 · 잘린 상자([서, 남, 동, 북] 경위도) · 기준점 ~ 가장 가까운 임도 m · 올린 파일. */
export interface PlanForestRoads {
count: number;
length_m: number;
total: number;
crs: string[];
bounds: [number, number, number, number] | null;
distance_m: number | null;
source: { files: string[]; bytes: number; uploaded_at: string } | null;
collection: GeoJsonCollection;
/** 잘린 임도 상자 [서, 남, 동, 북] 경위도 · 없으면 null(595-4). */
bounds?: [number, number, number, number] | null;
/** 기준점 ~ 가장 가까운 임도(m). */
distance_m?: number | null;
}
/** 올린 적 없으면 오류(404 글 그대로). */
+61 -29
View File
@@ -1,12 +1,13 @@
"""D 노선분석 지도 올린 임도망도 겹(PLAN 584-3 · 요구 443).
"""D 노선분석 지도 올린 임도망도 겹(PLAN 584-3 · 595-4 · 요구 443 · 463).
- 출처 = 사용자가 받은 산림청 임도망도 SHP(FGIS 무료신청 · 산림빅데이터거래소 · 시군구 단위 · 선)
- 올림 = zip 하나 또는 shp · shx · dbf(· prj · cpg) 묶음 · 합계 `MAX_BYTES` · 읽어 보고 받은 범위 안
선이 있어야 받음(아니면 옛 것 그대로 · 까닭 = `ValueError`)
- 올림 = zip 하나 또는 shp · shx · dbf(· prj · cpg) 묶음 · 합계 `MAX_BYTES` · 읽어 보고 자르는 범위
안 선이 있어야 받음(아니면 옛 것 그대로 · 까닭 = `ValueError` + 기준점 ~ 가장 가까운 임도 거리)
- 자르는 범위 = 기준점 ± `RADIUS_M` ∪ 받은 지형(`clip_box` · 지형 받기 전에도 기준점만으로)
- 원본 = 프로젝트 `D03_PlanAnalysis/임도망도.zip`(낱 파일은 zip 으로 묶음) +
`임도망도.json`(파일 이름 · 올린 때)
- 좌표계 = prj · 없으면 EPSG:5179 · dbf 글자 = cpg · 없으면 cp949 · 받은 범위로 잘라 가닥 · 길이 →
파생 저장본(열쇠 = 원본 지문 · 범위)
- 좌표계 = prj · 없으면 EPSG:5179 · dbf 글자 = cpg · 없으면 cp949 · 자르는 범위로 잘라 가닥 · 길이 ·
잘린 상자(`bounds` 경위도) · 기준점 거리 → 파생 저장본(열쇠 = 원본 지문 · 범위 · 기준점)
계산 자리 = ③ 서버 단독.
"""
@@ -32,6 +33,7 @@ from common_util.common_util_json import atomic_write_json
from common_util.common_util_time import utc_now_text
Box = tuple[float, float, float, float]
XY = tuple[float, float]
FOLDER = "D03_PlanAnalysis"
SOURCE_ZIP = "임도망도.zip"
@@ -41,7 +43,17 @@ DBF_ENCODING = "cp949"
MAX_BYTES = 100 * 1024 * 1024
SHP_PARTS = (".shp", ".shx", ".dbf", ".prj", ".cpg")
ROAD_KIND = "plan_forest_road"
ROAD_VERSION = 1
ROAD_VERSION = 2 # 잘린 상자 · 기준점 거리
RADIUS_M = 5000.0 # 기준점 둘레(요구서_463 3장 18)
def clip_box(origin: XY, have: Box | None) -> Box:
"""자르는 범위 = 기준점 ± `RADIUS_M` ∪ 받은 지형."""
x, y = origin
box = (x - RADIUS_M, y - RADIUS_M, x + RADIUS_M, y + RADIUS_M)
if have is None:
return box
return min(box[0], have[0]), min(box[1], have[1]), max(box[2], have[2]), max(box[3], have[3])
def source_dir(project_root: Path) -> Path:
@@ -100,18 +112,22 @@ def _plain(value: Any) -> Any:
return value if isinstance(value, (str, int, float, bool)) or value is None else str(value)
def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]:
"""SHP 선 → 받은 범위로 잘라 경위도 선(원 속성 + `length_m`) · 가닥 · 길이 · 원본 선 수 ·
좌표계."""
def clip_roads(zip_path: Path, box: Box, epsg: int, origin: XY | None = None) -> dict[str, Any]:
"""SHP 선 → 범위로 잘라 경위도 선(원 속성 + `length_m`) · 가닥 · 길이 · 원본 선 수 · 좌표계 ·
잘린 상자(`bounds` [서, 남, 동, 북] · 없으면 None) · 기준점 ~ 가장 가까운 임도(`distance_m` ·
자르기 전 모든 선)."""
from pyproj import Transformer
from shapely.geometry import Point, mapping
from shapely.geometry import box as rect
from shapely.geometry import mapping
from shapely.ops import transform
frame = rect(*box)
here = Point(origin) if origin else None
to_ll = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True).transform
features: list[dict[str, Any]] = []
total, length, crs = 0, 0.0, []
near: float | None = None
edges: list[tuple[float, float, float, float]] = []
for shapes in read_shapes(zip_path):
crs.append(shapes.crs.to_string())
shapes = shapes[shapes.geometry.notna()]
@@ -121,19 +137,18 @@ def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]:
for (_, row), geometry in zip(
shapes.drop(columns=shapes.geometry.name).iterrows(), metric.geometry
):
if here is not None:
gap = geometry.distance(here)
near = gap if near is None else min(near, gap)
part = geometry.intersection(frame)
if part.is_empty or part.length <= 0:
continue
length += part.length
props = {str(k): _plain(v) for k, v in row.items()}
props["length_m"] = round(part.length, 1)
features.append(
{
"type": "Feature",
"geometry": mapping(transform(to_ll, part)),
"properties": props,
}
)
lonlat = transform(to_ll, part)
edges.append(lonlat.bounds)
features.append({"type": "Feature", "geometry": mapping(lonlat), "properties": props})
if not total:
raise ValueError("SHP 에 선 지물이 없습니다 — 임도망도(선) 파일인지 보세요.")
return {
@@ -141,23 +156,34 @@ def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]:
"length_m": round(length, 1),
"total": total,
"crs": sorted(set(crs)),
"bounds": [
min(e[0] for e in edges),
min(e[1] for e in edges),
max(e[2] for e in edges),
max(e[3] for e in edges),
]
if edges
else None,
"distance_m": None if near is None else round(near, 1),
"collection": {"type": "FeatureCollection", "features": features},
}
def road_layer(project_root: Path, box: Box, epsg: int) -> dict[str, Any]:
"""올린 임도망도 — 원본 지문 · 범위 같으면 저장본 · 올린 적 없으면 `FileNotFoundError`."""
def road_layer(project_root: Path, box: Box, epsg: int, origin: XY | None = None) -> dict[str, Any]:
"""올린 임도망도 — 원본 지문 · 범위 · 기준점 같으면 저장본 · 올린 적 없으면
`FileNotFoundError`."""
path = source_dir(project_root) / SOURCE_ZIP
stamp = file_stamp(path)
if stamp is None:
raise FileNotFoundError(
"올린 임도망도가 없습니다 — 옆 올리기 단추로 시군구 SHP 를 올리세요."
"올린 임도망도가 없습니다 — [임도망 올리기]로 시군구 SHP 를 올리세요."
)
key = derived_key(ROAD_KIND, ROAD_VERSION, stamp, int(epsg), [round(v, 1) for v in box])
at = [round(v, 1) for v in origin] if origin else None
key = derived_key(ROAD_KIND, ROAD_VERSION, stamp, int(epsg), [round(v, 1) for v in box], at)
saved = read_derived(project_root, ROAD_KIND, key)
if saved is not None:
return saved["value"]
value = clip_roads(path, box, epsg)
value = clip_roads(path, box, epsg, origin)
try:
value["source"] = json.loads((source_dir(project_root) / SOURCE_META).read_text("utf-8"))
except (OSError, ValueError):
@@ -167,10 +193,14 @@ def road_layer(project_root: Path, box: Box, epsg: int) -> dict[str, Any]:
def save_roads(
project_root: Path, files: list[tuple[str, bytes]], box: Box, epsg: int
project_root: Path,
files: list[tuple[str, bytes]],
box: Box,
epsg: int,
origin: XY | None = None,
) -> dict[str, Any]:
"""올림 — 묶어 읽어 보고 받은 범위 안 선이 있으면 원본을 바꿈 · 아니면 `ValueError`
(옛 것 그대로)."""
"""올림 — 묶어 읽어 보고 자르는 범위 안 선이 있으면 원본을 바꿈 · 아니면 `ValueError`
(옛 것 그대로 · 기준점 ~ 가장 가까운 임도 거리)."""
data = pack(files)
folder = source_dir(project_root)
folder.mkdir(parents=True, exist_ok=True)
@@ -178,11 +208,13 @@ def save_roads(
temporary.write(data)
trial = Path(temporary.name)
try:
got = clip_roads(trial, box, epsg)
got = clip_roads(trial, box, epsg, origin)
if not got["count"]:
gap = got["distance_m"]
where = f" — 가장 가까운 임도 {gap / 1000:.1f} km" if gap is not None else ""
raise ValueError(
f"받은 범위 안에 임도가 없습니다(파일 선 {got['total']}가닥) — "
"사업지 시군구 파일인지 보세요."
f"임도가 기준점 ±{RADIUS_M / 1000:.0f} km · 받은 지형 밖뿐입니다{where}"
f"(파일 선 {got['total']}가닥) — 사업지 시군구 파일인지 보세요."
)
os.replace(trial, folder / SOURCE_ZIP)
finally:
@@ -195,4 +227,4 @@ def save_roads(
"uploaded_at": utc_now_text(),
},
)
return road_layer(project_root, box, epsg)
return road_layer(project_root, box, epsg, origin)
+36 -44
View File
@@ -5,7 +5,8 @@
한 번 1,000 필지 한도 → 꽉 차면 넷으로 다시 쪼갬 · `pnu` 로 겹친 필지 거름 ·
저장 = `processed/연속지적도_bounds.geojson`(B04 `/geojson?layer=지적도` 자리) + 곁 열쇠
- 급경사 = 평활 안 한 사업 DEM(`models_plan/dtm_sheet_<방법>.npz`) · E07 `slope_rad` ·
15° · 35° · 40° 단계 PNG · 파생 저장본(열쇠 = 격자 지문)
기울기 % 값 그림 한 장(1 % 단계 · 회색) + 1 % 칸 수 · 범위 색칠 · 비율 = 화면(PLAN 603) ·
파생 저장본(열쇠 = 격자 지문)
- 음영기복 = 같은 DEM · B07 `hillshade_png`(PLAN 581-1)
- 보호 · 규제 구역 = 브이월드 WFS 여덟 종(겹 이름 = `_분석_440-C` 표 1) ·
받은 범위로 잘라 종류 · 이름 · 넓이만 · 저장 = `processed/보호구역_bounds.geojson` + 곁 열쇠 ·
@@ -64,13 +65,9 @@ SLOPE_KIND = "plan_slope"
HILLSHADE_KIND = "plan_hillshade"
HILLSHADE_VERSION = 2 # 빈 칸 투명
RASTER_KINDS = ("slope", "hillshade")
SLOPE_VERSION = 1
# (아래 °, 위 °, 색 RGBA) — 임도망_편성 완 · 중 · 급경사지 · 타당성평가 35° · 노선_배치 40° 연속
SLOPE_BANDS = (
(15.0, 35.0, (250, 204, 21, 110)),
(35.0, 40.0, (249, 115, 22, 140)),
(40.0, 90.0, (220, 38, 38, 160)),
)
SLOPE_VERSION = 2 # 값 그림 + 1 % 칸 수(PLAN 603)
SLOPE_MAX_PCT = 250 # 칸 값 0~250 = 기울기 % 내림(250 = 250 % 이상)
SLOPE_EMPTY = 255 # 빈 칸
MAX_SIDE_PX = 4096 # ponytail: 한 변 넘으면 솎음 · 넓은 사업은 조각 그림으로
@@ -179,69 +176,64 @@ def _covers(have: Any, box: Box) -> bool:
def ensure_cadastral(processed: Path, box: Box, epsg: int) -> dict[str, Any]:
"""지적도 — 열쇠 상자가 덮고 실패 조각 없으면 그대로 · 아니면 상자 통째로 다시 받음."""
"""지적도 — 열쇠 상자가 덮고 실패 조각 없으면 그대로 · 덮는데 실패 조각이 남았으면 그 조각만
다시(PLAN 595-1) · 못 덮으면 상자 통째로 다시 받음."""
path, key_path = processed / CADASTRAL_FILE, processed / CADASTRAL_KEY
key = _read_json(key_path) or {}
if path.is_file() and not key.get("failed") and _covers(key.get("box"), box):
covered = path.is_file() and _covers(key.get("box"), box)
if covered and not key.get("failed"):
return {"path": path, "fetched": False, "count": key.get("count"), "failed": 0}
parts = tiles(box)
retry = key.get("failed_tiles") if covered else None # 옛 열쇠(조각 기록 없음) = 통째로
old = ((_read_json(path) or {}).get("features") or []) if retry else []
frame = key["box"] if retry else [float(v) for v in box]
parts = [tuple(t) for t in retry] if retry else tiles(box)
with ThreadPoolExecutor(max_workers=WFS_WORKERS) as pool:
results = list(pool.map(lambda part: fetch_tile(part, epsg), parts))
failed = sum(bad for _, bad in results)
if failed == len(parts):
raise RuntimeError(f"지적도를 받지 못했습니다 — 브이월드 WFS 조각 {failed}개 실패")
features = dedup_parcels([f for got, _ in results for f in got])
bad = [list(part) for part, (_, n) in zip(parts, results) if n]
if len(bad) == len(parts) and not old:
raise RuntimeError(f"지적도를 받지 못했습니다 — 브이월드 WFS 조각 {len(bad)}개 실패")
features = dedup_parcels([*old, *(f for got, _ in results for f in got)])
processed.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, {"type": "FeatureCollection", "features": features})
atomic_write_json(
key_path,
{
"box": [float(v) for v in box],
"box": frame,
"epsg": int(epsg),
"count": len(features),
"failed": failed,
"failed": len(bad),
"failed_tiles": bad,
},
)
logger.info("D 지적도 받기: 조각 %d · 실패 %d · 필지 %d", len(parts), failed, len(features))
return {"path": path, "fetched": True, "count": len(features), "failed": failed}
logger.info("D 지적도 받기: 조각 %d · 실패 %d · 필지 %d", len(parts), len(bad), len(features))
return {"path": path, "fetched": True, "count": len(features), "failed": len(bad)}
# ── 급경사 ──────────────────────────────────────────────
def slope_bands_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict[str, Any]:
"""격자(행 = 남 → 북) → 단계 PNG(위 = 북 · 15° 미만 · 빈 칸 투명) + 단계 칸 비율."""
def slope_values_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict[str, Any]:
"""격자(행 = 남 → 북) → 기울기 % 값 그림(회색 · 위 = 북 · 칸 값 = % 내림 0~250 · 빈 칸 255)
+ 1 % 칸 수(`counts[v]` = 값 v 칸 수 · 합 = 유효 칸)."""
from PIL import Image
from E07_StabArea.E07_StabArea_Engine import slope_rad
stride = max(1, math.ceil(max(z.shape) / MAX_SIDE_PX))
grid = np.where(valid, np.asarray(z, float), np.nan)[::stride, ::stride]
deg = np.degrees(slope_rad(grid, cell_m * stride))
known = np.isfinite(deg)
index = np.zeros(deg.shape, np.uint8)
for i, (low, high, _) in enumerate(SLOPE_BANDS, start=1):
index[known & (deg >= low) & (deg < high)] = i
total = max(1, int(known.sum()))
image = Image.fromarray(np.flipud(index), mode="P")
palette = [0, 0, 0] + [c for _, _, rgba in SLOPE_BANDS for c in rgba[:3]]
image.putpalette(palette + [0] * (768 - len(palette)))
alpha = bytes([0] + [rgba[3] for _, _, rgba in SLOPE_BANDS])
pct = np.tan(slope_rad(grid, cell_m * stride)) * 100.0
known = np.isfinite(pct)
value = np.full(pct.shape, SLOPE_EMPTY, np.uint8)
value[known] = np.minimum(np.floor(pct[known]), SLOPE_MAX_PCT).astype(np.uint8)
image = Image.fromarray(np.flipud(value), mode="L")
buffer = io.BytesIO()
image.save(buffer, format="PNG", optimize=True, transparency=alpha)
image.save(buffer, format="PNG", optimize=True)
counts = np.bincount(value[known], minlength=SLOPE_MAX_PCT + 1)
return {
"width": image.width,
"height": image.height,
"image": "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii"),
"bands": [
{
"min_deg": low,
"max_deg": high,
"color": "#{:02x}{:02x}{:02x}".format(*rgba[:3]),
"ratio": round(int((index == i).sum()) / total, 4),
}
for i, (low, high, rgba) in enumerate(SLOPE_BANDS, start=1)
],
"counts": [int(n) for n in counts],
}
@@ -263,17 +255,17 @@ def hillshade_layer_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict
def slope_layer(
project_root: Path, models_dir: Path, epsg: int, kind: str = "slope"
) -> dict[str, Any]:
"""급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 단계 · 판) 같으면 저장본."""
"""급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 판) 같으면 저장본."""
npz = models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz"
stamp = file_stamp(npz)
if stamp is None:
raise FileNotFoundError("사업 지형면이 없습니다 — 지형 받기 뒤 보입니다.")
store_kind, version, build = (
(SLOPE_KIND, SLOPE_VERSION, slope_bands_png)
(SLOPE_KIND, SLOPE_VERSION, slope_values_png)
if kind == "slope"
else (HILLSHADE_KIND, HILLSHADE_VERSION, hillshade_layer_png)
)
key = derived_key(store_kind, version, npz.name, stamp, int(epsg), SLOPE_BANDS, MAX_SIDE_PX)
key = derived_key(store_kind, version, npz.name, stamp, int(epsg), MAX_SIDE_PX)
saved = read_derived(project_root, store_kind, key)
if saved is not None:
return saved["value"]
+159 -29
View File
@@ -11,6 +11,10 @@
범위 = 기준점 둘레(`SITE_RADIUS_M`) + 지점 + 그은 점(`extra` · 지도 보기 귀) 상자 + 여유 ∪ 받은 범위
(PLAN 591-2 · 기준점 = 처음 받는 기준일 뿐).
위치 바꿈(PLAN 595 · 꼴 = PLAN 595 절) = 몸 `center`(위치 칸 캐시 기준점) 둘레를 더함 ·
받은 범위에서 `FAR_M` 넘음 = 「far」 물음 · 다른 TM 띠 = 「band」 물음 → `reset` = 옛 범위 버리고
center 둘레만 · 칸 `MAX_CELLS` 넘음 = 413 · 진행은 `models_plan/plan_job_<일>.json` 에도 적어
서버가 다시 켜지면 「끊김」(interrupted).
노선 정본(손 그은 선)은
`D03_PlanAnalysis_Network`(PLAN 562-2 · 옛 추천 엔진 · 꺾임점 R 걷음).
"""
@@ -28,8 +32,9 @@ from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFil
from pydantic import BaseModel
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_crs import lonlat_to_tm
from common_util.common_util_crs import lonlat_to_tm, tm_epsg_for_lon
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from D03_PlanAnalysis import D03_PlanAnalysis_ForestRoad as forest_road
@@ -50,26 +55,53 @@ _TASKS: set[asyncio.Task] = set()
KINDS = ("terrain", "analyze")
class Center(BaseModel):
lon: float
lat: float
class TerrainBody(BaseModel):
"""지형 받기 — 여유(m · 빈 값 = 처음 값) · 더 덮을 점(그은 꺾임점 · 작업 좌표계 m)."""
"""지형 받기 — 여유(m · 빈 값 = 처음 값) · 더 덮을 점(그은 꺾임점 · 작업 좌표계 m) ·
위치 칸 가운데(경위도 · 595) · 새로(옛 받은 범위 버림)."""
margin_m: float | None = None
extra: list[tuple[float, float]] = []
center: Center | None = None
reset: bool = False
async def _plan_ends(project_id: UUID) -> tuple[Path, int, list[tuple[float, float]]]:
"""(실경로, 좌표계, [지점들]) — 설계 내용 파일 · 지점 · 선이 없으면 등록 기준점 둘레 두 귀."""
Ends = tuple[Path, int, list[tuple[float, float]], int | None]
async def _plan_ends(
project_id: UUID, center: tuple[float, float] | None = None, reset: bool = False
) -> Ends:
"""(실경로, 좌표계, [지점들], 띠 넘음 epsg) — 설계 내용 파일 · 지점 · 선이 없으면 등록 기준점
둘레 두 귀 · `center`(위치 칸) = 그 둘레를 더함(`reset` = 둘레만) · 다른 TM 띠 = 확인 전이면
넷째 값에 그 띠(셋은 `center` 없이) · 확인 뒤(받은 지형이 그 띠) = 그 띠 · 둘레만."""
root, plan = await store.load_plan(project_id) # 없음 404 · 예정노선 아님 400
site = (await network.project_info(project_id))["site"]
epsg = network.work_epsg(plan, site)
meta = terrain.received_meta(root)
if center is None and meta.get("center") and meta.get("epsg") not in (None, epsg):
center = tuple(meta["center"]) # 띠 넘어 받은 뒤 [저장] 전 — 받은 지형 띠 · 가운데
ends = [(float(p["x"]), float(p["y"])) for p in plan["points"]]
if center is not None:
band = tm_epsg_for_lon(center[0])
if epsg is not None and band != epsg:
if not (reset or meta.get("epsg") == band):
return (*(await _plan_ends(project_id))[:3], band)
epsg, ends, reset = band, [], True # 옛 띠 지점 · 선 = 「노선 지움」 확인으로 빠짐
epsg = epsg or band
corners = terrain.site_corners(*lonlat_to_tm(center[0], center[1], epsg))
return root, epsg, corners if reset else [*ends, *corners], None
if epsg is None:
raise HTTPException(status_code=400, detail="기준점 · 지점이 없습니다.")
ends = [(float(p["x"]), float(p["y"])) for p in plan["points"]]
if site and not ends and not plan["lines"]: # 처음 = 기준점 둘레 · 그 뒤는 받은 범위가 덮음
ends = terrain.site_corners(*lonlat_to_tm(site["lon"], site["lat"], epsg))
if not ends:
raise HTTPException(status_code=400, detail="기준점 · 지점이 없습니다.")
return root, epsg, ends
return root, epsg, ends, None
def _query_xy(extra: str | None) -> list[tuple[float, float]]:
@@ -81,15 +113,49 @@ def _query_xy(extra: str | None) -> list[tuple[float, float]]:
raise HTTPException(status_code=400, detail="extra 꼴이 다릅니다.") from error
def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]:
IDLE = {"running": False, "percent": 100, "message": None, "error": None, "interrupted": False}
def _job_path(root: Path, kind: str) -> Path:
return terrain.models_dir_of(root) / f"plan_job_{kind}.json"
def _note_job(path: Path | None, job: dict[str, Any]) -> None:
"""진행 기록 — 서버가 다시 켜져도 「돌던 일」을 알게(실패해도 일은 계속)."""
if path is None:
return
try:
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, job)
except OSError as error:
logger.warning("D 진행 기록 실패: %s", error)
def _saved_job(root: Path, kind: str) -> dict[str, Any]:
"""기억에 없는 일 — 기록이 「도는 중」이면 서버가 다시 켜져 끊김(percent < 100 ·
message 없음)."""
try:
saved = json.loads(_job_path(root, kind).read_text(encoding="utf-8"))
except (OSError, ValueError):
return dict(IDLE)
if not (isinstance(saved, dict) and saved.get("running")):
return dict(IDLE)
percent = min(99, int(saved.get("percent") or 0))
return {**IDLE, "percent": percent, "interrupted": True}
def _start_job(project_id: UUID, kind: str, work: Any, root: Path | None = None) -> dict[str, Any]:
key = (str(project_id), kind)
if (_JOBS.get(key) or {}).get("running"):
raise HTTPException(status_code=409, detail="이미 돌고 있습니다.")
job = {"running": True, "percent": 0, "message": "시작", "error": None}
job = {"running": True, "percent": 0, "message": "시작", "error": None, "interrupted": False}
_JOBS[key] = job
path = _job_path(root, kind) if root is not None else None
_note_job(path, job)
def report(percent: int, message: str) -> None:
job.update(percent=int(percent), message=message)
_note_job(path, job)
async def run() -> None:
try:
@@ -100,6 +166,7 @@ def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]:
job["error"] = str(getattr(error, "detail", None) or error)
finally:
job["running"] = False
_note_job(path, job)
task = asyncio.create_task(run(), name=f"D {kind} {project_id}")
_TASKS.add(task)
@@ -133,10 +200,16 @@ async def _save_models(project_id: UUID, models: list[dict[str, Any]], epsg: int
@router.get("/{project_id}/plan/terrain/extent", response_model=None)
async def plan_terrain_extent(
project_id: UUID, margin_m: float | None = None, extra: str | None = None
project_id: UUID,
margin_m: float | None = None,
extra: str | None = None,
lon: float | None = None,
lat: float | None = None,
) -> dict[str, Any]:
"""받기 전 범위 — 지점(+ `extra` 그은 점 `[[x, y]]`) 상자 + 여유 ∪ 받은 범위."""
root, epsg, ends = await _plan_ends(project_id)
"""받기 전 범위 — 지점(+ `extra` 그은 점 `[[x, y]]` · `lon` `lat` 위치 칸 둘레) 상자 + 여유 ∪
받은 범위."""
center = (lon, lat) if lon is not None and lat is not None else None
root, epsg, ends, _ = await _plan_ends(project_id, center)
view = await asyncio.to_thread(
terrain.plan_extent, root, [*ends, *_query_xy(extra)], epsg, margin_m
)
@@ -145,34 +218,73 @@ async def plan_terrain_extent(
@router.post("/{project_id}/plan/terrain", response_model=None)
async def plan_terrain(project_id: UUID, body: TerrainBody | None = None) -> dict[str, Any]:
"""사업 전체 수치지형도 · 위성 · DEM 받기(뒤에서) — 진행은 `GET …/plan/progress`."""
"""사업 전체 수치지형도 · 위성 · DEM 받기(뒤에서) — 진행은 `GET …/plan/progress` ·
위치 칸 가운데가 멀거나 다른 띠 = 물음(`far` · `band`) · 칸 상한 넘음 = 413."""
body = body or TerrainBody()
root, epsg, ends = await _plan_ends(project_id)
center = (body.center.lon, body.center.lat) if body.center else None
root, epsg, ends, band = await _plan_ends(project_id, center, body.reset)
if band is not None:
return {
"status": "band",
"epsg": epsg,
"center_epsg": band,
"message": "다른 좌표 띠로 옮깁니다 — 그은 지점 · 선을 지우고 새로 받을까요?",
}
have = None if body.reset else terrain.received_box(root, epsg)
if center is not None and have is not None:
far = terrain.distance_to_box(lonlat_to_tm(center[0], center[1], epsg), have)
if far > terrain.FAR_M:
return {
"status": "far",
"distance_m": round(far),
"message": f"받은 지형에서 {far / 1000:.1f} km 떨어졌습니다 — "
"옛 받은 범위를 버리고 새로 받을까요?",
}
points = [*ends, *body.extra]
cells = terrain.cells_of(terrain.wanted_box(points, terrain.clamp_margin(body.margin_m), have))
if cells > terrain.MAX_CELLS:
raise HTTPException(
status_code=413,
detail=f"받을 범위가 너무 큽니다 — 칸 {cells / 1e4:.0f}만 · "
f"상한 {terrain.MAX_CELLS / 1e4:.0f}만(지점 · 선을 모으거나 위치를 옮겨 새로 받으세요)",
)
async def work(report: Any) -> None:
got = await asyncio.to_thread(
terrain.ensure_plan_terrain, root, points, epsg, body.margin_m, report
terrain.ensure_plan_terrain,
root,
points,
epsg,
body.margin_m,
report,
reset=have is None,
center=center,
)
await _save_models(project_id, got["models"], epsg)
report(100, f"지형 받기 끝 — 새 도엽 {len(got['sheets']['downloaded'])}매")
return _start_job(project_id, "terrain", work)
return _start_job(project_id, "terrain", work, root)
@router.get("/{project_id}/plan/progress", response_model=None)
async def plan_progress(project_id: UUID) -> dict[str, Any]:
idle = {"running": False, "percent": 0, "message": None, "error": None}
return {
"status": "success",
**{kind: dict(_JOBS.get((str(project_id), kind)) or idle) for kind in KINDS},
}
"""일마다 진행 — 기억에 없으면 기록을 봄(돌던 기록 = 끊김 · 없으면 percent 100)."""
jobs: dict[str, Any] = {}
for kind in KINDS:
job = _JOBS.get((str(project_id), kind))
if job is None:
try:
job = await asyncio.to_thread(_saved_job, await _tile_root(project_id), kind)
except HTTPException:
job = IDLE
jobs[kind] = dict(job)
return {"status": "success", **jobs}
async def _layer_root(project_id: UUID) -> tuple[Path, int, terrain.Box]:
"""(실경로, 좌표계, 받은 범위) — 지형 받기 전이면 404."""
root, epsg, _ = await _plan_ends(project_id)
box = terrain.received_box(root)
root, epsg, _, _ = await _plan_ends(project_id)
box = terrain.received_box(root, epsg)
if box is None:
raise HTTPException(status_code=404, detail="지형 받기 뒤 보입니다.")
return root, epsg, box
@@ -208,7 +320,7 @@ async def plan_layer_cadastral(project_id: UUID, request: Request) -> Response:
@router.get("/{project_id}/plan/layers/raster", response_model=None)
async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, Any]:
"""급경사 단계 그림(15° · 35° · 40°) · 음영기복(`hillshade`) — 격자 지문 같으면 저장본 ·
"""급경사 기울기 % 값 그림 + 1 % 칸 수(603) · 음영기복(`hillshade`) — 격자 지문 같으면 저장본 ·
산사태위험지도(`landslide` · 행안부 WMS) — 받은 범위를 덮으면 저장본."""
if kind == "landslide":
root, epsg, box = await _layer_root(project_id)
@@ -301,12 +413,30 @@ async def plan_layer_zero_order(
return {"status": "success", **value}
async def _road_frame(project_id: UUID) -> tuple[Path, int, terrain.Box, tuple[float, float]]:
"""(실경로, 좌표계, 자르는 범위, 기준점 m) — 기준점 = 위치 칸으로 받은 가운데 · 등록 기준점 ·
지점 가운데 차례 · 지형 받기 전에도(595-4)."""
root, epsg, ends, _ = await _plan_ends(project_id)
center = terrain.received_meta(root).get("center")
site = (await network.project_info(project_id))["site"]
if center:
origin = lonlat_to_tm(float(center[0]), float(center[1]), epsg)
elif site:
origin = lonlat_to_tm(site["lon"], site["lat"], epsg)
else:
xs, ys = [p[0] for p in ends], [p[1] for p in ends]
origin = ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2)
box = forest_road.clip_box(origin, terrain.received_box(root, epsg))
return root, epsg, box, origin
@router.get("/{project_id}/plan/layers/forest-roads", response_model=None)
async def plan_layer_forest_roads(project_id: UUID) -> dict[str, Any]:
"""올린 임도망도(받은 범위로 자른 선 · 가닥 · 길이) — 올린 적 없으면 404."""
root, epsg, box = await _layer_root(project_id)
"""올린 임도망도(기준점 ± 5 km ∪ 받은 지형으로 자른 선 · 가닥 · 길이 · 잘린 상자 · 기준점
거리) — 올린 적 없으면 404."""
root, epsg, box, origin = await _road_frame(project_id)
try:
value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg)
value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg, origin)
except FileNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
return {"status": "success", **value}
@@ -317,8 +447,8 @@ async def plan_layer_forest_roads_upload(
project_id: UUID, files: list[UploadFile] = File(...)
) -> dict[str, Any]:
"""임도망도 SHP 올리기(zip 하나 · shp · shx · dbf · prj 묶음 · 합계 100 MB) — 못 읽거나
받은 범위 안 선이 없으면 400(옛 것 그대로) · 넘치면 413."""
root, epsg, box = await _layer_root(project_id)
자르는 범위 안 선이 없으면 400(옛 것 그대로 · 가장 가까운 임도 거리) · 넘치면 413."""
root, epsg, box, origin = await _road_frame(project_id)
got: list[tuple[str, bytes]] = []
total = 0
for upload in files:
@@ -335,7 +465,7 @@ async def plan_layer_forest_roads_upload(
if not total:
raise HTTPException(status_code=400, detail="빈 파일입니다.")
try:
value = await asyncio.to_thread(forest_road.save_roads, root, got, box, epsg)
value = await asyncio.to_thread(forest_road.save_roads, root, got, box, epsg, origin)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
return {"status": "success", **value}
+69 -33
View File
@@ -1,13 +1,17 @@
"""D 예정노선 지형 받기 — 사업 전체 한 벌(도엽 · 위성 · DEM) (요구 345 · 389 · PLAN 530-1 · 561-2).
"""D 예정노선 지형 받기 — 사업 전체 한 벌(도엽 · 위성 · DEM) (요구 345 · 389 · 463 · PLAN 530-1 ·
561-2 · 595).
지형은 D 프로젝트 storage 안 `B04_PreProcess/` 배치 그대로(지형 코드 주인 = B04 · 고칠 곳 0):
- 범위 = 전체 시점 · 종점(+ 그은 점) 상자 + 여유 · 받은 범위가 있으면 그 범위와 합침(줄지 않음)
- 범위 = 전체 시점 · 종점(+ 그은 점) 상자 + 여유 · 받은 범위가 있으면 그 범위와 합침(줄지 않음) ·
새로(`reset` · 위치를 멀리 옮김) = 옛 받은 범위 · 병합 도엽 · 위성 범위를 버리고 새 상자만 ·
칸 상한 `MAX_CELLS`(넘으면 라우터가 거절)
- 도엽 = 범위 상자에 닿는 도엽 모두 → `ensure_sheets`(없는 것만 · prune 없음)
- 병합 = 지난번 병합 도엽 ∪ 이번 도엽(옛 도엽 유지 · 열쇠 같으면 건너뜀)
- 위성 = 범위 도엽 도곽 ∪ 옛 위성 범위(덮으면 건너뜀)
- 지적도 = 범위 상자 · 브이월드 WFS 1 km 조각(`D03_PlanAnalysis_Layers` · 실패해도 계속)
- 지적도 = 지형 일에서 뺌 — 겹을 켤 때 GET 이 받음(`D03_PlanAnalysis_Layers.ensure_cadastral`)
- DEM = 한 벌 `models_plan/`(연도는 D03 나눔 뒤에 정해짐) · 받은 범위 = `plan_terrain.json`
(상자 · 여유 · 좌표계 · 위치 칸 가운데)
- 작업 좌표계 = 지점 epsg · `B03_FileInput/input/prj/plan.prj` 한 장(B04 · B05 좌표계 창구가 읽음)
- 받기 전 범위 보기 = `plan_extent`(내려받기 없음) · 받기는 사용자가 누를 때만
@@ -43,7 +47,6 @@ from config.config_system_terrain import (
SHEET_SURFACE_DEFAULT_METHOD,
SHEET_SURFACE_MARGIN_M,
)
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
logger = logging.getLogger(__name__)
@@ -52,6 +55,9 @@ DEFAULT_MARGIN_M = 350.0
MAX_MARGIN_M = 5000.0
# 처음 받는 범위 = 등록 기준점 둘레 ± 1 km(요구 453 · PLAN 591-2)
SITE_RADIUS_M = 1000.0
# 위치 바꿈이 받은 범위에서 이만큼 넘게 멀면 묻고 새로 · 받을 DEM 칸 상한(요구서_463 3장 4)
FAR_M = 5000.0
MAX_CELLS = 5_000_000
STAGE = Path("B04_PreProcess")
PLAN_MODELS = "models_plan"
TERRAIN_META = "plan_terrain.json"
@@ -74,9 +80,16 @@ def clamp_margin(margin_m: float | None) -> float:
return min(MAX_MARGIN_M, max(SHEET_SURFACE_MARGIN_M, float(margin_m)))
def received_box(project_root: Path) -> Box | None:
"""받은 DEM 범위 — 받은 적 없거나 읽지 못하면 None."""
meta = _read_json(models_dir_of(project_root) / TERRAIN_META) or {}
def received_meta(project_root: Path) -> dict[str, Any]:
"""받은 범위 기록(`box` · `margin_m` · `epsg` · `center`) — 없으면 빈 dict."""
return _read_json(models_dir_of(project_root) / TERRAIN_META) or {}
def received_box(project_root: Path, epsg: int | None = None) -> Box | None:
"""받은 DEM 범위 — 받은 적 없거나 읽지 못하거나 다른 좌표계(`epsg` 줄 때)면 None."""
meta = received_meta(project_root)
if epsg is not None and meta.get("epsg") not in (None, int(epsg)):
return None
try:
x0, y0, x1, y1 = (float(v) for v in meta["box"])
except (KeyError, TypeError, ValueError):
@@ -99,6 +112,18 @@ def wanted_box(points: list[XY], margin_m: float, have: Box | None) -> Box:
return min(box[0], have[0]), min(box[1], have[1]), max(box[2], have[2]), max(box[3], have[3])
def cells_of(box: Box) -> int:
"""DEM 칸 수 어림 — 받기 시간 · 메모리 가늠 · 상한 판정."""
return int((box[2] - box[0]) * (box[3] - box[1]) / PLAN_TERRAIN_GRID_M**2)
def distance_to_box(xy: XY, box: Box) -> float:
"""점 ~ 상자 거리(m · 안이면 0)."""
dx = max(box[0] - xy[0], 0.0, xy[0] - box[2])
dy = max(box[1] - xy[1], 0.0, xy[1] - box[3])
return math.hypot(dx, dy)
def covers(have: Box | None, box: Box) -> bool:
return (
have is not None
@@ -135,7 +160,7 @@ def plan_extent(
) -> dict[str, Any]:
"""받기 전 범위 보기 — 내려받기 없음. `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m)."""
margin = clamp_margin(margin_m)
have = received_box(project_root)
have = received_box(project_root, epsg)
box = wanted_box(points, margin, have)
store = get_project_map_sheets_dir(project_root)
sheets = []
@@ -157,8 +182,8 @@ def plan_extent(
"have": box_ring(have, epsg) if have else None,
"covered": covers(have, box),
"sheets": sheets,
# DEM 칸 수 어림 — 받기 시간 · 메모리 가늠
"cells": int((box[2] - box[0]) * (box[3] - box[1]) / PLAN_TERRAIN_GRID_M**2),
"cells": cells_of(box),
"max_cells": MAX_CELLS,
}
@@ -181,10 +206,12 @@ def _read_json(path: Path) -> dict[str, Any] | None:
return None
def _merge_sheets(processed: Path, store: Path, available: list[str]) -> list[str]:
"""병합 = 지난번 병합 도엽(zip 남은 것) ∪ 이번 도엽 · 열쇠 같으면 건너뜀."""
def _merge_sheets(
processed: Path, store: Path, available: list[str], keep_old: bool = True
) -> list[str]:
"""병합 = 지난번 병합 도엽(zip 남은 것 · 새로면 뺌) ∪ 이번 도엽 · 열쇠 같으면 건너뜀."""
key_path = processed / "sheet_merge.key.json"
old = (_read_json(key_path) or {}).get("sheets") or []
old = ((_read_json(key_path) or {}).get("sheets") or []) if keep_old else []
sheets = sorted({*available, *(s for s in old if get_sheet_path(store, s) is not None)})
key = {"sheets": sheets, "parser": PARSER_VERSION}
if sheets and (_read_json(key_path) != key or not any(processed.glob("도엽_*.geojson"))):
@@ -193,8 +220,15 @@ def _merge_sheets(processed: Path, store: Path, available: list[str]) -> list[st
return sheets
def _satellite(processed: Path, prj: Path, points: list[tuple[float, float]], epsg: int) -> str:
"""위성 = 기준 도엽 도곽 ∪ 옛 범위 · 이미 덮으면 건너뜀. 돌려줌 = skip · done · failed."""
def _satellite(
processed: Path,
prj: Path,
points: list[tuple[float, float]],
epsg: int,
keep_old: bool = True,
) -> str:
"""위성 = 기준 도엽 도곽 ∪ 옛 범위(새로면 뺌) · 이미 덮으면 건너뜀. 돌려줌 = skip · done ·
failed."""
from pyproj import Transformer
to_metric = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True)
@@ -207,7 +241,7 @@ def _satellite(processed: Path, prj: Path, points: list[tuple[float, float]], ep
xs.append(x)
ys.append(y)
need = {"x": [min(xs), max(xs)], "y": [min(ys), max(ys)]}
meta = _read_json(processed / "vworld_satellite_meta.json")
meta = _read_json(processed / "vworld_satellite_meta.json") if keep_old else None
try:
old = [float(meta[k]) for k in ("x_min", "x_max", "y_min", "y_max")] if meta else None
except (KeyError, TypeError, ValueError):
@@ -234,8 +268,11 @@ def ensure_plan_terrain(
epsg: int,
margin_m: float | None = None,
on_progress: Progress | None = None,
reset: bool = False,
center: tuple[float, float] | None = None,
) -> dict[str, Any]:
"""사업 전체 지형 받기 — `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m).
"""사업 전체 지형 받기 — `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m) ·
`reset` = 옛 받은 범위 버리고 새로 · `center` = 위치 칸 가운데(경위도 · 기록만).
돌려줌 = 도엽(받은 · 실패 · 병합) · 위성 상태 · 받은 범위 · 모델 등록용 dict 목록.
"""
@@ -250,28 +287,24 @@ def ensure_plan_terrain(
processed.mkdir(parents=True, exist_ok=True)
prj = write_plan_prj(project_root, epsg)
margin = clamp_margin(margin_m)
box = wanted_box(points, margin, received_box(project_root))
have = None if reset else received_box(project_root, epsg)
box = wanted_box(points, margin, have)
report(5, "수치지형도 도엽 고르는 중")
report(5, "1/5 수치지형도 도엽 고르는 중")
wanted = sheets_for_box(box, epsg)
store = get_project_map_sheets_dir(project_root)
before = {s for s in wanted if get_sheet_path(store, s) is not None}
report(10, f"수치지형도 도엽 {len(wanted) - len(before)}매 받는 중")
report(10, f"2/5 수치지형도 도엽 {len(wanted) - len(before)}매 받는 중")
got = ensure_sheets(store, wanted)
if not got["available"]:
raise ValueError(f"수치지형도 도엽을 하나도 받지 못했습니다: {got['failed']}")
report(45, "도엽 레이어 병합 중")
merged = _merge_sheets(processed, store, list(got["available"]))
report(60, "위성 지도 받는 중")
satellite = _satellite(processed, prj, [(p[1], p[0]) for p in box_ring(box, epsg)], epsg)
report(62, "지적도 받는 중")
try:
cadastral = layers.ensure_cadastral(processed, box, epsg)["count"]
except Exception as exc: # noqa: BLE001 — 지적도는 겹 · 지형 받기를 막지 않음(켤 때 다시)
logger.warning("D 지적도 받기 실패: %s", exc)
cadastral = None
report(45, "3/5 도엽 레이어 병합 중")
merged = _merge_sheets(processed, store, list(got["available"]), keep_old=have is not None)
report(60, "4/5 위성 지도 받는 중")
ring = [(p[1], p[0]) for p in box_ring(box, epsg)]
satellite = _satellite(processed, prj, ring, epsg, keep_old=have is not None)
report(65, "사업 지형면 만드는 중")
report(65, f"5/5 사업 지형면 만드는 중 — 칸 {cells_of(box) / 1e4:.0f}만 · 수 분 걸릴 수 있음")
models_dir = models_dir_of(project_root)
models_dir.mkdir(parents=True, exist_ok=True)
# B 절취 = 넘긴 꼭짓점 상자 + `SHEET_SURFACE_MARGIN_M` → 그만큼 안쪽으로 넘김(여유 ≥ 그 값)
@@ -293,7 +326,11 @@ def ensure_plan_terrain(
"사업 지형면을 만들지 못했습니다 — 범위 안 도엽 등고선 높이 확인"
f"(병합 도엽 {len(merged)}매)"
)
atomic_write_json(models_dir / TERRAIN_META, {"box": list(box), "margin_m": margin})
meta: dict[str, Any] = {"box": list(box), "margin_m": margin, "epsg": int(epsg)}
kept = None if reset else received_meta(project_root).get("center")
if center or kept:
meta["center"] = list(center or kept)
atomic_write_json(models_dir / TERRAIN_META, meta)
report(100, "지형 받기 끝")
return {
"sheets": {
@@ -303,7 +340,6 @@ def ensure_plan_terrain(
"merged": merged,
},
"satellite": satellite,
"cadastral": cadastral,
"box_m": list(box),
"models": models,
}
@@ -6,7 +6,7 @@
* 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 목록 줄과 같은 `pointColor(i)` · 끌기 · 메뉴 = `D03_PlanAnalysis_UI_Points`.
* ========================================================================== */
import { pointColor } from "@ui/ui_template_palette";
import { pointColor, routeColor } from "@ui/ui_template_palette";
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import {
distanceAlong,
@@ -18,6 +18,7 @@ import {
type HeightAt,
} from "./logic/D03_PlanAnalysis_Draw";
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { pointLabel } from "./logic/D03_PlanAnalysis_PointEdit";
import { nodeOff } from "./logic/D03_PlanAnalysis_Span";
import {
planColors,
@@ -128,7 +129,7 @@ export function paintNetwork(
context.lineJoin = "round";
network.lines.forEach((line, index) => {
if (line.vertices.length < 2) return;
context.strokeStyle = index === active ? colors.active : colors.line;
context.strokeStyle = index === active ? colors.active : routeColor(index);
context.lineWidth = line.branch ? 2.5 : 3;
const path = (vertices: ReadonlyArray<Vertex>): void => {
context.beginPath();
@@ -181,7 +182,7 @@ export function paintNetwork(
}
/** 지점 핀 겹(585-1) — 꼭지 = 지점 · 둥근 머리 · 「지점 N」 · 화면 px 고정 크기 · 돌린 지도에서도 섬. */
export function pointLayer(points: () => Vertex[]): PlanMapLayer {
export function pointLayer(points: () => Vertex[], names: () => string[] = () => []): PlanMapLayer {
return {
draw(context, map) {
points().forEach((point, i) => {
@@ -206,7 +207,7 @@ export function pointLayer(points: () => Vertex[]): PlanMapLayer {
context.fillStyle = "#ffffff";
context.fill();
});
planLabel(context, map, sx, sy - 38, `지점 ${i + 1}`, "bold 12px");
planLabel(context, map, sx, sy - 38, pointLabel(names(), i), "bold 12px");
});
},
};
+274 -261
View File
@@ -1,57 +1,67 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Edit.ts
* D03 고르기 · 고치기 겹(PLAN 580 · 585-2 · 요구서 440 3 · 6 · 7 · 445 A4 · A5) — 쉴 때(긋기 · 재기 아님)만.
* D03 고르기 · 고치기 겹(PLAN 580 · 585-2 · 598 · 요구 463 9 · 12 · 13·16 · 15) — 쉴 때(긋기 · 재기 아님)만.
*
* 고름 = 구간(조각) 묶음 + 넓이(구간 · 선 · 같은 해) — 선 누름 = 그 구간 · 이미 고른 곳 다시 누름 = 넓이 한 단 위
* (구간 → 선 → 같은 해 → 구간) · Shift+누름 = 구간 더함 · 뺌 · 빈 곳 누름 · Esc = 풂.
* 빈 곳 왼쪽 끌기 = 끌어 고르기(오토캐드 꼴 · 보이는 화면 가로 방향) — 오른쪽으로 = 창(실선 · 다 든 구간만) ·
* 왼쪽으로 = 걸침(점선 · 걸친 구간까지) · Shift = 지금 고름에 더함.
* 고른 선 위 끌기 = 꺾임점 더해 옮김(누름만은 넓힘) · 꺾임점 끌기 = 옮김(붙은 갈래 첫 점 같이) ·
* 마크 누름 = 뒤 구간 고름 + 마크 밝힘 · 끌기 = 연도 구분 옮김 · 오른쪽 누름 = 꺾임점 · 마크 지움 ·
* Delete = 고른 마크 지움 · 고른 구간이 선 통째면 그 선 지움.
* 연도 구역 마커(585-3) = 도구줄 「연도 마커」를 선 위로 끌어 놓음 = 그 자리 구간을 둘로(해 그대로 · 구역만 나눔 ·
* 해는 고른 뒤 속성 판) · 끄는 동안 마름모 미리보기(못 놓음 = 경고 색) · 놓인 마크를 선 밖으로 끌어냄 = 지움(합침).
* 잠금 = 지점에 놓인 점(지점 핀을 끌면 같이 감 · 오른쪽 누름 = 지도 메뉴 591-2) · 갈래 첫 점(붙은 선 점을 끌면 같이 감).
* 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 · 넓이 = 화면 기억(저장 안 함).
* 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 `select` · `setScope` · 동작 셋을 부름).
* 고름 층 = 노드 → 선(두 노드 사이 토막) → 구역(그은 꺾은선) → 전체(`UI_Select`) — 노드 누름 = 노드 · 선 위 누름 = 토막 ·
* 고른 것 다시 누름 = 한 단 넓힘(전체 다음 = 처음) · Shift+누름 = 토막 더함 · 뺌 · 빈 곳 누름 · Esc = 풂.
* 「같은 해」 = 왼쪽 해 줄로만(`selectYear`).
* 빈 곳 왼쪽 끌기 = 끌어 고르기(오토캐드 꼴 · 토막) — 오른쪽으로 = 창(실선 · 다 든 토막만) ·
* 왼쪽으로 = 걸침(점선 · 걸친 토막까지) · Shift = 지금 고름에 더함.
* 노드 끌기 = 옮김(붙은 갈래 첫 점 같이) · 지점 자리 노드 = 지점도 같이 · 지점 12 px 안에 놓으면 그 자리로 붙음 ·
* 고른 토막 위 끌기 = 꺾임점 더해 옮김(누름만은 넓힘) · 마크 누름 = 뒤 구간 고름 + 마크 밝힘 · 끌기 = 옮김 ·
* 선 밖으로 끌어냄 = 지움(합침).
* 지움 = Delete · 속성 판 · 왼쪽 목록 · 우클릭 메뉴가 같은 함수 — 노드 · 토막(가운데 = 구역 둘) · 구역 · 전체 · 마크.
* 우클릭 = 메뉴(`UI_Menu` — 이 겹은 오른쪽 누름을 안 받음).
* 연도 구역 마커(585-3) = 「연도 마커」를 선 위로 끌어 놓음 · 메뉴 「여기서 연도 구역 나눔」(`splitAt`) = 그 자리 구간을 둘로.
* 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 = 화면 기억(저장 안 함).
* 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 동작을 부름).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { themeColor } from "@ui/ui_template_palette";
import { keyIs } from "../A00_Common/key_registry";
import {
distanceAlong,
lineLength,
pointAtDistance,
sliceLine,
} from "./logic/D03_PlanAnalysis_Draw";
import { distanceAlong, pointAtDistance } from "./logic/D03_PlanAnalysis_Draw";
import {
insertVertex,
moveMark,
moveNodesAt,
moveVertex,
piecesInBox,
removeLine,
removeMark,
removeVertex,
reverseLine,
samePoint,
splitPiece,
branchFromEnd,
type PlanNetwork,
type Vertex,
type ViewBox,
} from "./logic/D03_PlanAnalysis_Plan";
import { lineAt, paintMark, planLabel, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
import {
movePointsAt,
nodeSegment,
removeAll,
removeSegments,
segmentsInBox,
type PlanNode,
type PlanSegment,
} from "./logic/D03_PlanAnalysis_Select";
import {
lineAt,
paintMark,
planLabel,
screenGap,
SNAP_PX,
strokeCased,
} from "./D03_PlanAnalysis_UI_DrawPaint";
import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
import { createPlanSelect, type PlanScope, type PlanSelection } from "./D03_PlanAnalysis_UI_Select";
export type { PlanScope, PlanSelection } from "./D03_PlanAnalysis_UI_Select";
/** 끌기로 볼 마우스 움직임(px) — 이보다 적으면 누름. */
const DRAG_PX = 4;
/** 마크를 선에서 이만큼(px) 넘게 끌어내면 지움. */
const MARK_OFF_PX = 3 * SNAP_PX;
export type PlanScope = "piece" | "line" | "year";
const NEXT_SCOPE: Record<PlanScope, PlanScope> = { piece: "line", line: "year", year: "piece" };
export interface PlanEditParams {
network: () => PlanNetwork;
/** 지점(긋기에 붙는 점 · 591-2). */
@@ -59,6 +69,9 @@ export interface PlanEditParams {
commit: (next: PlanNetwork) => void;
/** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */
show: (next: PlanNetwork) => void;
/** 지점 자리 노드를 끎 — 지점 · 노선 한 걸음 · 화면만. */
commitPoints: (points: Vertex[], next: PlanNetwork) => void;
showPoints: (points: Vertex[], next: PlanNetwork) => void;
/** 고르기 받는 때(쉴 때 · 재기 아님). */
enabled: () => boolean;
/** 고치기 됨(산림청). */
@@ -67,151 +80,123 @@ export interface PlanEditParams {
onSelect: () => void;
}
/** 고름 — 구간 id 묶음 · 넓이 · 넓힘 씨(넓이를 바꿀 때 기준 구간) · 고른 마크(그 마크가 여는 구간 id). */
export interface PlanSelection {
pieces: string[];
scope: PlanScope;
seed: string | null;
/** 누른 자리 — 마크(구간 id) · 노드 · 토막(선 위 · 노드면 넓힐 토막). */
export interface PlanHit {
mark: string | null;
node: PlanNode | null;
segment: PlanSegment | null;
}
export interface PlanEdit {
layer: PlanMapLayer;
selected: () => PlanSelection | null;
/** 구간 id 묶음 고름(빈 묶음 = 풂) · 넓이 = 구간. */
/** 밝힐 꺾은선(고른 토막 · 구역 · 전체 · 구간). */
paths: () => Vertex[][];
/** 구간 id 묶음 고름(빈 묶음 = 풂) · 마크 고름. */
select: (pieces: string[], mark?: string | null) => void;
/** 씨 구간 기준 넓이 바꿈(구간 · 선 · 같은 해). */
/** 구간 고름 넓이 바꿈(구간 · 선 · 같은 해 — 왼쪽 목록). */
setScope: (scope: PlanScope) => void;
/** 고른 구간이 든 선 하나 — 방향 바꾸기 · 지움(선 둘 이상 · 없음 = 까닭 글). */
flipLine: () => void;
dropLine: () => void;
selectNode: (node: PlanNode) => void;
selectRegion: (line: string) => void;
selectYear: (year: number) => void;
selectAll: () => void;
/** 고른 것 지움(Delete · 판 [지움]). */
dropSelected: () => void;
dropNode: (node: PlanNode) => void;
dropMark: () => void;
/** 갈래 시점 지정 — 다른 선에 닿은 끝을 시점으로 그 선의 갈래(585-3). */
branchLine: () => void;
/** 고른 구간이 모두 한 선이면 그 선 차례(아니면 −1). */
lineIndex: () => number;
dropRegion: (line: string) => void;
/** 전체 지움 — 선 · 구역 모두(지점 남김) · 되돌리기 한 걸음. */
dropAll: () => void;
/** 이 자리 선 위에 연도 구역 마커 — 놓음 = 참. */
splitAt: (px: number, py: number, map: ScreenMap) => boolean;
/** 이 자리에 무엇이 있나(우클릭 메뉴). */
hit: (px: number, py: number, map: ScreenMap) => PlanHit;
/** 키 — 받았으면 참(Delete · Esc). */
key: (event: KeyboardEvent) => boolean;
/** 되돌리기 · 저장 뒤 — 사라진 구간은 뺌 · 다 사라지면 풂. */
/** 되돌리기 · 저장 뒤 — 사라진 것은 뺌 · 다 사라지면 풂. */
sync: () => void;
/** 검증 창구 — 지금 끄는 상자(보이는 px · 방향). */
box: () => (ViewBox & { crossing: boolean }) | null;
}
type Drag =
| { kind: "vertex"; line: number; vertex: number; before: PlanNetwork; last: PlanNetwork | null }
| { kind: "mark"; index: number; before: PlanNetwork; last: PlanNetwork | null }
| { kind: "locked"; note: string; told: boolean }
/** 고른 구간 다시 누름 — 놓으면 넓힘 · 끌면(고칠 수 있을 때) 꺾임점 더해 옮김. */
| {
kind: "grow";
piece: string;
kind: "vertex";
line: number;
vertex: number;
/** 끌기 앞 자리 · 같이 가는 지점(같은 자리)이 있나. */
from: Vertex;
follow: boolean;
before: PlanNetwork;
points: Vertex[];
last: [Vertex[], PlanNetwork] | null;
}
| { kind: "mark"; index: number; before: PlanNetwork; last: PlanNetwork | null }
/** 노드 · 선 누름 — 놓으면(고른 것이었으면) 넓힘 · 끌면 노드 옮김 · 고른 토막이면 꺾임점 더해 옮김. */
| {
kind: "press";
x: number;
y: number;
hit: { line: number; segment: number; point: Vertex } | null;
node: PlanNode | null;
on: { line: number; segment: number; point: Vertex } | null;
grow: boolean;
}
/** 빈 곳 끌기 — 보이는 px 시작 · 지금. */
| { kind: "box"; box: ViewBox; shift: boolean };
export function createPlanEdit(params: PlanEditParams): PlanEdit {
let selected: PlanSelection | null = null;
const sel = createPlanSelect(params.network, params.onSelect);
let drag: Drag | null = null;
/** 「연도 마커」 끄는 중 미리보기 — 놓일 자리 · 놓을 수 있음 · 글. */
let markDrop: { point: Vertex; ok: boolean; text: string } | null = null;
const pieceIds = (network: PlanNetwork): Set<string> =>
new Set(network.pieces.map((piece) => piece.id));
const lineIndexOf = (network: PlanNetwork, id: string): number =>
network.lines.findIndex((line) => line.id === id);
/** 씨 구간 → 넓이만큼 구간 묶음. */
function expand(seed: string, scope: PlanScope): string[] {
const network = params.network();
const piece = network.pieces.find((p) => p.id === seed);
if (!piece) return [];
if (scope === "piece") return [seed];
return network.pieces
.filter((p) => (scope === "line" ? p.line === piece.line : p.year === piece.year))
.map((p) => p.id);
}
function choose(next: PlanSelection | null): void {
selected = next && next.pieces.length ? next : null;
params.onSelect();
}
function select(pieces: string[], mark: string | null = null): void {
choose({ pieces: [...new Set(pieces)], scope: "piece", seed: pieces[0] ?? null, mark });
}
function setScope(scope: PlanScope): void {
const seed = selected?.seed ?? selected?.pieces[0];
if (!seed) return;
choose({ pieces: expand(seed, scope), scope, seed, mark: null });
}
function lineIndex(): number {
const network = params.network();
if (!selected) return -1;
const lines = new Set(
selected.pieces.map((id) => network.pieces.find((p) => p.id === id)?.line),
);
if (lines.size !== 1) return -1;
const [id] = lines;
return network.lines.findIndex((line) => line.id === id);
}
/** 고른 구간의 선에 꼴 바꿈 하나 — 까닭 글이면 알림. */
function apply(step: (network: PlanNetwork, index: number) => PlanNetwork | string): void {
const network = params.network();
const index = lineIndex();
/** 꼴 바꿈 하나 — 까닭 글이면 알림. */
function apply(next: PlanNetwork | string): void {
if (!params.editable()) return;
if (index < 0) return void showToast("선 하나의 구간만 골랐을 때 됩니다.", "info");
const next = step(network, index);
if (typeof next === "string") return void showToast(next, "info");
params.commit(next);
sync();
sel.sync();
}
function dropMark(): void {
const network = params.network();
const piece = network.pieces.find((p) => p.id === selected?.mark);
if (!piece || !params.editable()) return;
params.commit(removeMark(network, piece.line, piece.from_m, 1e-3));
sync();
const piece = network.pieces.find((p) => p.id === sel.get()?.mark);
if (piece) apply(removeMark(network, piece.line, piece.from_m, 1e-3));
}
/** Delete — 마크 · 선 통째 고름 = 그 선. */
function dropSelected(): void {
if (selected?.mark) return dropMark();
function dropNode(node: PlanNode): void {
const network = params.network();
const index = lineIndex();
const whole =
index >= 0 &&
network.pieces
.filter((p) => p.line === network.lines[index].id)
.every((p) => selected!.pieces.includes(p.id));
if (!whole)
return void showToast("선 넓이로 고르면 지웁니다(다시 누름 · 속성 판 [선]).", "info");
apply((net, i) => removeLine(net, i));
const index = lineIndexOf(network, node.line);
if (index >= 0) apply(removeVertex(network, index, node.vertex));
}
/** 사라진 구간 뺌 — 부르는 쪽(페이지 `refresh`)이 다시 그림(여기서 onSelect 를 부르면 돌고 돎). */
function sync(): void {
if (!selected) return;
const ids = pieceIds(params.network());
const pieces = selected.pieces.filter((id) => ids.has(id));
selected = pieces.length
? {
...selected,
pieces,
seed: selected.seed && ids.has(selected.seed) ? selected.seed : pieces[0],
mark: selected.mark && ids.has(selected.mark) ? selected.mark : null,
}
: null;
function dropRegion(line: string): void {
const network = params.network();
const index = lineIndexOf(network, line);
if (index >= 0) apply(removeLine(network, index));
}
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;
function dropAll(): void {
const network = params.network();
if (!network.lines.length) return;
apply(removeAll(network));
showToast("노선을 모두 지웠습니다 — 되돌리기(Ctrl+Z) · [저장] 때 정본.", "info");
}
/** Delete · 판 [지움] — 고름 꼴마다. */
function dropSelected(): void {
const picked = sel.get();
if (!picked) return;
if (picked.kind === "mark") return dropMark();
if (picked.kind === "node") return dropNode(picked.node!);
if (picked.kind === "segment") return apply(removeSegments(params.network(), picked.segments));
if (picked.kind === "line") return dropRegion(picked.line!);
if (picked.kind === "all") return dropAll();
showToast("연도 구간은 지우지 않습니다 — 노드 · 선 · 구역으로 고르세요.", "info");
}
/** 화면 px 둘레 마크(조각 시작) — 조각 차례. */
function markAt(network: PlanNetwork, px: number, py: number, map: ScreenMap): number | null {
@@ -245,12 +230,33 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
return best;
}
/** 잠금 까닭 — 지점에 놓인 점. */
function lockOf(network: PlanNetwork, line: number, vertex: number): string | null {
const point = network.lines[line].vertices[vertex];
return params.base().some((p) => samePoint(p, point))
? "지점 자리 점은 지점 핀을 끌어 옮깁니다."
: null;
/** 지점 12 px 안이면 그 자리(노드가 붙음) — `skip` 자리 지점(같이 가는 것) 빼고. */
function pointNear(px: number, py: number, map: ScreenMap, skip: Vertex): Vertex | null {
const near = params
.base()
.filter((p) => !samePoint(p, skip))
.map((p) => ({ p, gap: screenGap(map, p, px, py) }))
.filter((item) => item.gap <= SNAP_PX)
.sort((a, b) => a.gap - b.gap)[0];
return near ? near.p : null;
}
function hit(px: number, py: number, map: ScreenMap): PlanHit {
const network = params.network();
const mark = markAt(network, px, py, map);
if (mark !== null) return { mark: network.pieces[mark].id, node: null, segment: null };
const vertex = vertexAt(network, px, py, map);
if (vertex) {
const node = { line: network.lines[vertex.line].id, vertex: vertex.vertex };
return { mark: null, node, segment: nodeSegment(network, node) };
}
const on = lineAt(network, px, py, map);
if (!on) return { mark: null, node: null, segment: null };
return {
mark: null,
node: null,
segment: { line: network.lines[on.line].id, index: on.segment },
};
}
/** 「연도 마커」를 이 자리에 놓으면 — 다음 꼴 · 새 뒤 구간 id(못 놓음 = null). */
@@ -260,66 +266,62 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
map: ScreenMap,
): { point: Vertex; next: PlanNetwork | null; piece: string | null } {
const network = params.network();
const hit = lineAt(network, px, py, map);
if (!hit) return { point: map.toMetric(px, py), next: null, piece: null };
const next = splitPiece(network, network.lines[hit.line].id, hit.atM);
const on = lineAt(network, px, py, map);
if (!on) return { point: map.toMetric(px, py), next: null, piece: null };
const next = splitPiece(network, network.lines[on.line].id, on.atM);
const added = next.pieces.find((piece) => !network.pieces.some((p) => p.id === piece.id));
return { point: hit.point, next: added ? next : null, piece: added?.id ?? null };
return { point: on.point, next: added ? next : null, piece: added?.id ?? null };
}
/** 구간 누름 — Shift = 더함 · 뺌 · 이미 고른 곳 = 넓힘(놓을 때) · 아니면 그 구간. */
function pressPiece(
piece: string,
shift: boolean,
x: number,
y: number,
hit: { line: number; segment: number; point: Vertex } | null,
): void {
if (shift && selected) {
const has = selected.pieces.includes(piece);
const pieces = has
? selected.pieces.filter((id) => id !== piece)
: [...selected.pieces, piece];
return choose({ pieces, scope: "piece", seed: has ? pieces[0] : piece, mark: null });
function splitAt(px: number, py: number, map: ScreenMap): boolean {
if (!params.enabled() || !params.editable()) return false;
const place = markPlace(px, py, map);
if (!place.next || !place.piece) {
showToast("선 위(끝 · 다른 마커 1 m 밖)에 놓으세요.", "info");
return false;
}
if (selected?.pieces.includes(piece) && !selected.mark) {
drag = { kind: "grow", piece, x, y, hit };
return;
}
select([piece]);
params.commit(place.next);
sel.pieces([place.piece], place.piece);
return true;
}
/** 고른 선 꼭짓점 손잡이 그릴 선(고른 구간이 든 선). */
const pickedLines = (network: PlanNetwork): Set<string> =>
new Set(
(selected?.pieces ?? []).map((id) => network.pieces.find((p) => p.id === id)?.line ?? ""),
);
/** 노드 끌기 시작 — 지점 자리면 지점도 같이. */
function startVertex(line: number, vertex: number): Drag {
const before = params.network();
const from = before.lines[line].vertices[vertex];
const points = params.base();
const follow = points.some((p) => samePoint(p, from));
return { kind: "vertex", line, vertex, from, follow, before, points, last: null };
}
/** 고른 노드 · 토막 · 구역이 든 선(꺾임점 손잡이). */
function pickedLines(network: PlanNetwork): Set<string> {
const picked = sel.get();
if (!picked) return new Set();
if (picked.kind === "all") return new Set(network.lines.map((line) => line.id));
const ids = picked.segments.map((seg) => seg.line);
for (const id of picked.pieces) ids.push(network.pieces.find((p) => p.id === id)?.line ?? "");
if (picked.line) ids.push(picked.line);
return new Set(ids);
}
const layer: PlanMapLayer = {
draw(context, map) {
const network = params.network();
const colors = planColors();
const path = (points: Vertex[]): void => {
const picked = sel.get();
context.lineCap = "round";
context.lineJoin = "round";
for (const points of sel.paths()) {
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.8;
context.lineWidth = 8;
for (const id of selected?.pieces ?? []) {
const piece = network.pieces.find((p) => p.id === id);
const vertices = network.lines.find((line) => line.id === piece?.line)?.vertices;
if (piece && vertices) path(sliceLine(vertices, piece.from_m, piece.to_m));
strokeCased(context, colors.active, 8); // 밝은 바탕 대비 = 검은 테(600-1)
}
context.globalAlpha = 1;
// 꺾임점 손잡이(고른 구간이 든 선)
// 꺾임점 손잡이(고른 것이 든 선)
const lines = pickedLines(network);
context.lineWidth = 2;
context.fillStyle = "#ffffff";
@@ -331,11 +333,16 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
context.strokeRect(sx - 4, sy - 4, 8, 8);
}
}
// 고른 마크 테
const mark = network.pieces.find((p) => p.id === selected?.mark);
// 고른 노드 · 마크 테
const node = picked?.kind === "node" ? picked.node : null;
const nodePoint = node
? network.lines.find((line) => line.id === node.line)?.vertices[node.vertex]
: null;
const mark = network.pieces.find((p) => p.id === picked?.mark);
const markLine = network.lines.find((line) => line.id === mark?.line)?.vertices;
if (mark && markLine) {
const [sx, sy] = map.toScreen(pointAtDistance(markLine, mark.from_m));
const ring = nodePoint ?? (mark && markLine ? pointAtDistance(markLine, mark.from_m) : null);
if (ring) {
const [sx, sy] = map.toScreen(ring);
context.beginPath();
context.arc(sx, sy, 12, 0, Math.PI * 2);
context.lineWidth = 3;
@@ -378,48 +385,28 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
}
},
press(px, py, map, button, shift) {
if (!params.enabled()) return false;
if (!params.enabled() || button !== 0) return false; // 우클릭 = 메뉴(UI_Menu)
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 || lockOf(network, vertex.line, vertex.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.id], piece.id);
if (editable) drag = { kind: "mark", index: mark, before: network, last: null };
sel.pieces([piece.id], piece.id);
if (params.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));
const piece = pieceAt(network, id, atM);
if (piece && !selected?.pieces.includes(piece)) pressPiece(piece, shift, px, py, null);
const lock = lockOf(network, vertex.line, vertex.vertex);
if (editable && !shift)
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 piece = pieceAt(network, network.lines[hit.line].id, hit.atM);
if (piece) pressPiece(piece, shift, px, py, hit);
const at = hit(px, py, map);
if (at.segment) {
if (shift) return (sel.segments([at.segment], true), true);
const grow = sel.holds({
node: at.node ?? undefined,
segment: at.node ? undefined : at.segment,
});
if (!grow) {
if (at.node) sel.node(at.node);
else sel.segments([at.segment]);
}
const on = at.node ? null : lineAt(network, px, py, map);
drag = { kind: "press", x: px, y: py, node: at.node, on, grow };
return true;
}
const [vx, vy] = map.rerotate(px, py);
@@ -432,31 +419,34 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
[drag.box.x1, drag.box.y1] = map.rerotate(px, py);
return;
}
if (drag.kind === "grow") {
if (drag.kind === "press") {
if (Math.hypot(px - drag.x, py - drag.y) <= DRAG_PX) return;
const hit = drag.hit;
if (!params.editable() || !hit) return void (drag = null);
params.commit(insertVertex(params.network(), hit.line, hit.segment, hit.point));
const before = params.network();
drag = { kind: "vertex", line: hit.line, vertex: hit.segment + 1, before, last: null };
}
if (drag.kind === "locked") {
if (!drag.told) showToast(drag.note, "info");
drag.told = true;
return;
const { node, on, grow } = drag;
const network = params.network();
if (!params.editable()) return void (drag = null);
if (node) drag = startVertex(lineIndexOf(network, node.line), node.vertex);
else if (grow && on) {
params.commit(insertVertex(network, on.line, on.segment, on.point));
drag = startVertex(on.line, on.segment + 1);
} else return void (drag = null);
}
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;
const atM = distanceAlong(vertices, cursor);
const off = screenGap(map, pointAtDistance(vertices, atM), px, py) > MARK_OFF_PX;
drag.last = off
? removeMark(drag.before, piece.line, piece.from_m, 1e-3)
: moveMark(drag.before, drag.index, atM);
const to = pointNear(px, py, map, drag.from) ?? cursor;
drag.last = drag.follow
? [movePointsAt(drag.points, drag.from, to), moveNodesAt(drag.before, drag.from, to)]
: [drag.points, moveVertex(drag.before, drag.line, drag.vertex, to)];
params.showPoints(...drag.last);
return;
}
if (drag.kind !== "mark") return;
const piece = drag.before.pieces[drag.index];
const vertices = drag.before.lines.find((line) => line.id === piece.line)!.vertices;
const atM = distanceAlong(vertices, cursor);
const off = screenGap(map, pointAtDistance(vertices, atM), px, py) > MARK_OFF_PX;
drag.last = off
? removeMark(drag.before, piece.line, piece.from_m, 1e-3)
: moveMark(drag.before, drag.index, atM);
params.show(drag.last);
},
dragOver(px, py, map) {
@@ -476,11 +466,7 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
drop(px, py, map) {
markDrop = null;
if (!params.enabled() || !params.editable()) return false;
const place = markPlace(px, py, map);
if (!place.next || !place.piece)
return (void showToast("선 위(끝 · 다른 마커 1 m 밖)에 놓으세요.", "info"), true);
params.commit(place.next);
select([place.piece], place.piece);
splitAt(px, py, map);
return true;
},
leave() {
@@ -489,56 +475,83 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
release(map) {
const done = drag;
drag = null;
if (!done || done.kind === "locked") return;
if (done.kind === "grow") {
if (selected) {
const scope = NEXT_SCOPE[selected.scope];
choose({ pieces: expand(done.piece, scope), scope, seed: done.piece, mark: null });
}
if (!done) return;
if (done.kind === "press") {
if (done.grow) sel.grow();
return;
}
if (done.kind === "box") {
const { box, shift } = done;
if (Math.hypot(box.x1 - box.x0, box.y1 - box.y0) <= DRAG_PX) {
if (!shift) choose(null);
if (!shift) sel.clear();
return;
}
const network = params.network();
const toView = (point: Vertex): Vertex => map.rerotate(...map.toScreen(point));
const ids = piecesInBox(network, toView, box, box.x1 < box.x0);
select(shift && selected ? [...selected.pieces, ...ids] : ids);
const found = segmentsInBox(network, toView, box, box.x1 < box.x0);
const now = sel.get();
sel.segments(shift && now?.kind === "segment" ? [...now.segments, ...found] : found);
return;
}
if (!done.last) return;
params.show(done.before);
params.commit(done.last);
sync();
if (done.kind === "vertex") {
params.showPoints(done.points, done.before);
params.commitPoints(...done.last);
} else {
params.show(done.before);
params.commit(done.last);
}
sel.sync();
},
};
/** 왼쪽 목록 넓이 — 씨 구간(처음 고른 구간) 기준. */
function setScope(scope: PlanScope): void {
const network = params.network();
const seed = network.pieces.find((p) => p.id === sel.get()?.pieces[0]);
if (!seed) return;
if (scope === "line") return sel.region(seed.line);
if (scope === "piece") return sel.pieces([seed.id]);
const same = network.pieces.filter((p) => p.year === seed.year).map((p) => p.id);
sel.pieces([seed.id, ...same], null, "year");
}
return {
layer,
selected: () => selected,
select,
selected: sel.get,
paths: sel.paths,
select: (pieces, mark = null) => sel.pieces(pieces, mark),
setScope,
flipLine: () => apply((network, index) => reverseLine(network, index)),
dropLine: () => apply((network, index) => removeLine(network, index)),
selectNode: sel.node,
selectRegion: sel.region,
selectYear(year) {
const ids = params
.network()
.pieces.filter((p) => p.year === year)
.map((p) => p.id);
sel.pieces(ids, null, "year");
},
selectAll: sel.all,
dropSelected,
dropNode,
dropMark,
branchLine: () => apply((network, index) => branchFromEnd(network, index)),
lineIndex,
dropRegion,
dropAll,
splitAt,
hit,
key(event) {
if (!selected || !params.enabled()) return false;
if (!sel.get() || !params.enabled()) return false;
if (keyIs(event, "plan.delete") && params.editable()) {
dropSelected();
return true;
}
if (event.key === "Escape") {
choose(null);
sel.clear();
return true;
}
return false;
},
sync,
sync: sel.sync,
box: () => (drag?.kind === "box" ? { ...drag.box, crossing: drag.box.x1 < drag.box.x0 } : null),
};
}
@@ -55,7 +55,7 @@ export const GROUPS: readonly (readonly LayerDef[])[] = [
{ key: "satellite", icon: "satellite", title: "위성사진", on: true },
{ key: "hillshade", icon: "sun", title: "음영기복(2 m 지형 · 산줄기 · 골)", on: false },
{ key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true },
{ key: "slope", icon: "angle", title: "급경사 색(15° · 35° · 40° 이상)", on: false },
{ key: "slope", icon: "angle", title: "급경사 색(기울기 % 범위 · 범례 ⚙ 로 바꿈)", on: false },
],
[
{ key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true },
@@ -1,6 +1,7 @@
/* =============================================================================
* D03_PlanAnalysis_UI_LayerLegend.ts
* D03 바탕 겹 받은 자료 꼴 · 범례 줄 · 겹별 수(PLAN 569-2 · 581 · 584-1 · 584-3) — 켠 겹의 받은 범위 안 수 · 넓이.
* 급경사 = 고른 범위(603)마다 % · ° · 비율(1 % 칸 수로 바로 셈).
* 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기).
* ========================================================================== */
@@ -23,6 +24,12 @@ import {
type LineLayerKey,
type PlanLayerKey,
} from "./D03_PlanAnalysis_UI_LayerDefs";
import {
SLOPE_MAX_PCT,
bandRatios,
pctToDeg,
type SlopeBand,
} from "./logic/D03_PlanAnalysis_SlopeBands";
/** 선 겹 — 그릴 선 · 받은 범위 안 가닥 · 길이 m. */
export interface PreparedLines {
@@ -39,7 +46,15 @@ export type Prepared = Record<LineLayerKey, PreparedLines> & {
road: { all: PreparedLayer; small: PreparedLayer };
/** 올린 임도망도(584-3). */
forestRoadFile: { layer: PreparedLayer; info: PlanForestRoads };
slope: { raster: PlanRaster; image: HTMLImageElement };
/** 급경사(603) — 값 그림 칸 · 고른 범위 · 칠한 그림(blob 주소) · 칠하기 ms. */
slope: {
raster: PlanRaster;
image: HTMLImageElement;
values: ImageData;
bands: SlopeBand[];
url: string;
paintMs: number;
};
hillshade: { raster: PlanRaster; image: HTMLImageElement };
landslide: { raster: PlanRaster; image: HTMLImageElement };
/** 산사태취약지역(584-3) — 점(경위도) · 지번 맞는 필지 테두리. */
@@ -55,6 +70,19 @@ export type Prepared = Record<LineLayerKey, PreparedLines> & {
const km = (m: number): string => `${(m / 1000).toFixed(2)} km`;
/** 급경사 범례 — 범위마다 「아래~위 %(°) · 비율」(위 = 끝 = 「↑」). */
function slopeRow({ raster, bands }: Prepared["slope"]): LegendItem[] {
const ratios = bandRatios(raster.counts ?? [], bands);
const deg = (pct: number): string => pctToDeg(pct).toFixed(0);
return bands.map((band, i) => {
const open = band.high >= SLOPE_MAX_PCT;
const range = open
? `${band.low} %↑(${deg(band.low)}°↑)`
: `${band.low}~${band.high} %(${deg(band.low)}~${deg(band.high)}°)`;
return { label: `${range} · ${(ratios[i] * 100).toFixed(1)} %`, color: `var(${band.color})` };
});
}
/** 산사태취약지역 범례 — 점 · 필지 · 위치 없는 시도 · 표 없는 시도 · 지적 못 받음. */
function landslideZoneRow({ info }: Prepared["landslideZones"]): LegendItem[] {
const color = "var(--draw-map-landslide-zone)";
@@ -86,13 +114,7 @@ export function legendRows(
data: Partial<Prepared>,
): LegendItem[][] {
const rows: LegendItem[][] = [];
if (on.slope && data.slope)
rows.push(
data.slope.raster.bands.map((band) => ({
label: `${band.min_deg}${band.max_deg < 90 ? `~${band.max_deg}°` : "°↑"} ${(band.ratio * 100).toFixed(1)} %`,
color: band.color,
})),
);
if (on.slope && data.slope) rows.push(slopeRow(data.slope));
if (on.landslide && data.landslide) rows.push(LANDSLIDE_LEGEND);
if (on.landslideZones && data.landslideZones) rows.push(landslideZoneRow(data.landslideZones));
if (on.zones && data.zones) {
@@ -211,6 +233,17 @@ export const layerCounts = (data: Partial<Prepared>): Record<string, number> =>
band_road_m2: data.bands.info.road.area_m2,
}),
...(data.hillshade && { hillshade_px: data.hillshade.image.naturalWidth }),
...(data.slope && {
slope_px: data.slope.image.naturalWidth,
slope_cells: (data.slope.raster.counts ?? []).reduce((sum, n) => sum + n, 0),
slope_paint_ms: data.slope.paintMs,
...Object.fromEntries(
bandRatios(data.slope.raster.counts ?? [], data.slope.bands).map((r, i) => [
`slope_band_${i}`,
r,
]),
),
}),
...(data.landslide && { landslide_px: data.landslide.image.naturalWidth }),
...Object.fromEntries(
LINE_KEYS.flatMap((key) => {
+60 -2
View File
@@ -16,6 +16,7 @@
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`).
* 급경사 = 서버 값 그림을 고른 범위대로 칠함 · 범례 ⚙ → 범위 모달(603 · `D03_PlanAnalysis_UI_SlopeBands`).
* GL 바탕(586-2 · 처음 = 켬 · 끝 단추 끔 = 옛 캔버스 · `aislo:pref:d03.canvas`) — 겹 스물 · 등고 글 · 지명을
* MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스 바탕 겹은 쉼(등고 간격 셈만) · 받기 · 범례는 같음 ·
* WebGL 못 씀 · 잃음 = 저절로 옛 캔버스 + 알림(취향은 안 바꿈).
@@ -72,6 +73,14 @@ import {
import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend";
import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint";
import { createGlBase, GL_FONT, type GlImage } from "./D03_PlanAnalysis_UI_MapGl";
import {
openSlopeBands,
paintSlope,
readSlopeBands,
slopeGear,
slopeValues,
writeSlopeBands,
} from "./D03_PlanAnalysis_UI_SlopeBands";
import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
@@ -200,9 +209,39 @@ export function createPlanLayers(opts: {
gl.setData("hillshade", hillshade && (on.satellite ? hillshade.shade : hillshade.plain));
const rows = meta ? legendRows(on, data) : [];
legend.replaceChildren(...rows.map((row) => createLegend(row)));
if (meta && on.slope && data.slope) legend.firstElementChild?.append(gear);
legend.hidden = !rows.length;
}
/** 급경사 범위(603) — 바꾸면 범례 바로 · 그림은 다시 칠함(늦은 칠하기는 버림). */
let slopeBands = readSlopeBands();
let slopeTurn = 0;
const gear = slopeGear(() =>
openSlopeBands(slopeBands, (bands) => {
slopeBands = bands;
writeSlopeBands(bands);
if (data.slope) data.slope.bands = bands;
sync();
void repaintSlope();
}),
);
async function repaintSlope(): Promise<void> {
const got = data.slope;
if (!got) return;
const turn = ++slopeTurn;
got.paintMs = -1; // 칠하는 중(검증 창구)
const started = performance.now();
const painted = await paintSlope(got.values, got.bands);
if (turn !== slopeTurn || data.slope !== got) return URL.revokeObjectURL(painted.url);
const old = got.url;
Object.assign(got, { image: painted.image, url: painted.url });
got.paintMs = Math.round(performance.now() - started);
gl.setData("slope", glImage(got.raster, painted.url));
if (old) URL.revokeObjectURL(old);
sync();
map.redrawBase();
}
sync();
useGl(glOn);
@@ -257,9 +296,13 @@ export function createPlanLayers(opts: {
);
return info;
});
// 거절은 아래 catch 가 알림 — 기다림 몫은 삼킴(처리 안 된 거절 없음)
pending.set(
"forestRoadFile",
job.then(() => undefined),
job.then(
() => undefined,
() => undefined,
),
);
try {
return await job;
@@ -353,7 +396,22 @@ export function createPlanLayers(opts: {
} as GeoJsonCollection);
if (info.missing.length)
showToast(`산사태취약지역 — ${info.missing.join(" · ")} 자료 없음`, "info");
} else if (key === "slope" || key === "hillshade" || key === "landslide") {
} else if (key === "slope") {
const raster = await fetchPlanRaster(projectId, key);
const values = slopeValues(await loadImage(raster.image));
const bands = slopeBands;
const started = performance.now();
const painted = await paintSlope(values, bands);
if (data.slope?.url) URL.revokeObjectURL(data.slope.url);
data.slope = { raster, values, bands, ...painted, paintMs: 0 };
data.slope.paintMs = Math.round(performance.now() - started);
gl.setData("slope", glImage(raster, painted.url));
// 칠하는 동안 범위가 바뀜 = 새 범위로 다시
if (bands !== slopeBands) {
data.slope.bands = slopeBands;
void repaintSlope();
}
} else if (key === "hillshade" || key === "landslide") {
const raster = await fetchPlanRaster(projectId, key);
const image = await loadImage(raster.image);
data[key] = { raster, image };
+22 -7
View File
@@ -16,6 +16,7 @@
* 모두 `view` 를 바꾸고 `draw()` → 한 화면 한 번 `glCamera` 로 MapLibre 를 맞춘 뒤 캔버스를 그림.
* ========================================================================== */
import { perfTime } from "@ui/ui_template_gl_map";
import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools";
import { routeColor, themeColor } from "@ui/ui_template_palette";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
@@ -60,6 +61,9 @@ export function planColors(): {
};
}
/** 줌아웃 한도(600-3) — 화면 폭 약 600 km(한반도 · 위성 z6). 휠 하한 배율 = 이 폭이 화면에 드는 값(B05 는 0.5 그대로). */
export const MAP_MAX_VIEW_WIDTH_M = 600_000;
/** 위성이 없을 때 맞출 점 둘레로 둘 가짜 도엽 여유(m). */
const FAKE_MARGIN_M = 600;
@@ -400,9 +404,11 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
function flush(): void {
cancelAnimationFrame(frame);
frame = 0;
if (baseDirty) glCamera();
if (baseDirty) paint(baseContext, true);
if (topDirty) paint(context, false);
perfTime("plan:flush", () => {
if (baseDirty) perfTime("plan:glCamera", glCamera);
if (baseDirty) perfTime("plan:paintBase", () => paint(baseContext, true));
if (topDirty) perfTime("plan:paintTop", () => paint(context, false));
});
baseDirty = topDirty = false;
}
/** 다음 화면에 한 번 — 바탕까지(화면 · 겹이 바뀜) 또는 위만(마우스). */
@@ -449,10 +455,12 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
});
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);
drawTop();
perfTime("plan:pointermove", () => {
const [px, py] = at(event);
const map = screenMap();
for (const layer of layers) layer.hover?.(px, py, map);
drawTop();
});
});
canvas.addEventListener("pointerleave", () => {
for (const layer of layers) layer.leave?.();
@@ -496,6 +504,13 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
getMeta: () => meta,
unrotateDelta: rotation.unrotateDelta,
rotateWheel: rotation.wheel,
minScale: () =>
meta && view.mapRect.width > 0
? Math.min(
0.5,
(view.width * meta.width_meters) / (view.mapRect.width * MAP_MAX_VIEW_WIDTH_M),
)
: 0.5,
draw,
});
+69 -17
View File
@@ -2,40 +2,72 @@
* D03_PlanAnalysis_UI_Measure.ts
* D03 거리재기 겹(PLAN 562-3) — 두 점을 누르면 그 사이 길이(m) · 원지반 기울기(%) · 높이 차(m).
* 셋째 누름 = 새로 잼 · Esc = 지움. 지반고 = 받은 지형 `heightAt` · 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`).
* 600-2 — 첫 누름이 노선 위면 「노선 따라」: 둘째 점 = 이어진 노선 위 가장 가까운 자리 · 길이 = 노선 따라
* (`logic/D03_PlanAnalysis_Measure`) · 첫 누름이 선 밖이면 곧은 거리 그대로.
* ========================================================================== */
import { groundSlopePct, segmentLength } from "./logic/D03_PlanAnalysis_Draw";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
import {
nearestOnRoute,
routeSpan,
type RoutePos,
type RouteSpan,
} from "./logic/D03_PlanAnalysis_Measure";
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
import { strokeCased } from "./D03_PlanAnalysis_UI_DrawPaint";
import { lineAt, strokeCased } from "./D03_PlanAnalysis_UI_DrawPaint";
export interface PlanMeasure {
layer: PlanMapLayer;
/** 지움(Esc · 끔). */
clear: () => void;
/** 검증 창구 — 잰 값(두 점이 다 있으면). */
result: () => { lengthM: number; slopePct: number | null; riseM: number | null } | null;
/** 검증 창구 — 잰 값(두 점이 다 있으면) · `alongRoute` = 노선 따라 쟀나. */
result: () => {
lengthM: number;
slopePct: number | null;
riseM: number | null;
alongRoute: boolean;
} | null;
}
const fmt = (value: number, digits = 1): string => value.toFixed(digits);
const signed = (value: number, digits = 1): string =>
`${value >= 0 ? "+" : ""}${fmt(value, digits)}`;
export function createPlanMeasure(heightAt: () => (x: number, y: number) => number): PlanMeasure {
export function createPlanMeasure(
heightAt: () => (x: number, y: number) => number,
network: () => PlanNetwork,
): PlanMeasure {
let from: Vertex | null = null;
let to: Vertex | null = null;
let fixed = false;
/** 첫 누름이 노선 위였던 자리 · 둘째 점이 노선 위에 놓인 길. */
let fromPos: RoutePos | null = null;
let span: RouteSpan | null = null;
const measure = (a: Vertex, b: Vertex) => {
const z = heightAt();
const rise = z(b[0], b[1]) - z(a[0], a[1]);
const lengthM = span ? span.lengthM : segmentLength(a, b);
return {
lengthM: segmentLength(a, b),
slopePct: groundSlopePct(a, b, z),
lengthM,
slopePct: span
? Number.isFinite(rise) && lengthM > 0
? (rise / lengthM) * 100
: null
: groundSlopePct(a, b, z),
riseM: Number.isFinite(rise) ? rise : null,
alongRoute: !!span,
};
};
/** 둘째 점 놓기 — 노선 위 첫 점이면 이어진 노선 위 가까운 자리 · 아니면 누른 자리. */
const place = (point: Vertex): void => {
const near = fromPos ? nearestOnRoute(network(), point, fromPos) : null;
span = near && fromPos ? routeSpan(network(), fromPos, near) : null;
to = near && span ? near.point : point;
};
const layer: PlanMapLayer = {
draw(context, map: ScreenMap) {
if (!from) return;
@@ -50,18 +82,33 @@ export function createPlanMeasure(heightAt: () => (x: number, y: number) => numb
context.strokeStyle = colors.casing;
context.stroke();
};
context.lineCap = "round";
context.lineJoin = "round";
if (to) {
const [bx, by] = map.toScreen(to);
context.beginPath();
context.moveTo(ax, ay);
context.lineTo(bx, by);
strokeCased(context, colors.measure, 2.5, [6, 4]);
if (span) {
span.path.forEach((point, i) => {
const [sx, sy] = map.toScreen(point);
if (i) context.lineTo(sx, sy);
else context.moveTo(sx, sy);
});
strokeCased(context, colors.measure, 4);
} else {
context.moveTo(ax, ay);
context.lineTo(bx, by);
strokeCased(context, colors.measure, 2.5, [6, 4]);
}
dot(ax, ay);
dot(bx, by);
const { lengthM, slopePct, riseM } = measure(from, to);
const text =
`${fmt(lengthM)} m · ` +
(slopePct === null ? "지형 밖" : `${signed(slopePct)} % · Δh ${signed(riseM ?? 0)} m`);
const rise = riseM === null ? "지형 밖" : `Δh ${signed(riseM)} m`;
const text = span
? `노선 따라 ${fmt(lengthM)} m · ` +
(span.stations ? `측점 ${fmt(span.stations[0])} ~ ${fmt(span.stations[1])} m · ` : "") +
rise
: `${fmt(lengthM)} m · ` +
(slopePct === null ? "지형 밖" : `${signed(slopePct)} % · ${rise}`);
const [mx, my] = [(ax + bx) / 2, (ay + by) / 2 - 14];
map.upright(context, mx, my, () => {
context.font = "12px system-ui, sans-serif";
@@ -81,21 +128,26 @@ export function createPlanMeasure(heightAt: () => (x: number, y: number) => numb
if (button !== 0) return false;
const point = map.toMetric(px, py);
if (!from || fixed) {
[from, to, fixed] = [point, null, false];
// 첫 누름 — 노선 위(선 8 px · 꺾임점 12 px)면 노선 따라 · 아니면 곧은 거리
const net = network();
const hit = lineAt(net, px, py, map);
fromPos = hit ? { line: net.lines[hit.line].id, atM: hit.atM } : null;
[from, to, fixed, span] = [hit ? hit.point : point, null, false, null];
} else {
[to, fixed] = [point, true];
place(point);
fixed = true;
}
return true;
},
hover(px, py, map) {
if (from && !fixed) to = map.toMetric(px, py);
if (from && !fixed) place(map.toMetric(px, py));
},
};
return {
layer,
clear: () => {
[from, to, fixed] = [null, null, false];
[from, to, fixed, fromPos, span] = [null, null, false, null, null];
},
result: () => (from && to && fixed ? measure(from, to) : null),
};
@@ -0,0 +1,106 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Menu.ts
* D03 지도 우클릭 메뉴(PLAN 591-2 · 598-2 · 요구 463 16 · 22) — 부품 상자 `createMapContextMenu` 하나 · 누른 자리마다 항목.
* 긋는 중 = 긋기 끝 · 핀 = 그 지점에서 긋기 · 지우기 · 마커 = 마커 지움 ·
* 노드 = 노드 지움 · 지점으로 · 이어 긋기 / 갈래 시작 · 선 위 = 갈래 시작 · 여기서 연도 구역 나눔 · 다시 긋기(599) ·
* 빈 땅 = 여기서 긋기 시작 · 핀 · 노드 밖 = 지점 놓기(노드 둘레면 그 노드에 붙음).
* 우클릭 즉시 지움 없음 — 지움은 메뉴 · 고르고 Delete. B05 우클릭(`bindNodeSplice`)과 코드 안 나눔.
* 겹 = 맨 끝(맨 위) · 오른쪽 누름만 받음.
* ========================================================================== */
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { samePoint, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan";
import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw";
import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit";
import type { PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
import type { PlanPoints } from "./D03_PlanAnalysis_UI_Points";
export interface PlanMenuParams {
network: () => PlanNetwork;
points: () => Vertex[];
marks: PlanPoints;
edit: PlanEdit;
draw: PlanDraw;
editable: () => boolean;
/** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */
respan: (line: string, from: number, to: number) => boolean;
}
export interface PlanMenu {
layer: PlanMapLayer;
/** 메뉴를 띄울 지도 그릇(우클릭 자리 = 그릇 기준). */
mount: (viewport: HTMLElement) => void;
}
export function createPlanMenu(params: PlanMenuParams): PlanMenu {
const { marks, edit, draw } = params;
const menu = createMapContextMenu("d03-plan");
let menuAt: [number, number] = [0, 0];
const layer: PlanMapLayer = {
draw: () => undefined,
press(px, py, map, button) {
if (button !== 2 || !params.editable()) return false;
const items: MapContextMenuItem[] = [];
const pin = marks.pinAt(px, py, map);
const at = pin === null ? edit.hit(px, py, map) : null;
const start = (x: number, y: number): void => {
const label = {
tip: "이어 긋기",
point: "지점에서 긋기 시작",
line: "갈래 시작",
empty: "여기서 긋기 시작",
}[draw.aim(x, y, map)];
items.push([label, () => draw.startAt(x, y, map)]);
};
if (draw.mode() === "draw" && draw.stroke())
items.push(["긋기 끝", () => draw.setMode("idle")]);
else if (pin !== null) {
const [sx, sy] = map.toScreen(params.points()[pin]); // 핀 머리를 눌러도 지점 자리에서
items.push([`${marks.name(pin)}에서 긋기 시작`, () => draw.startAt(sx, sy, map)]);
items.push([`${marks.name(pin)} 지우기`, () => marks.remove(pin)]);
} else if (at?.mark) {
const mark = at.mark;
items.push([
"마커 지움",
() => {
edit.select([mark], mark);
edit.dropMark();
},
]);
} else if (at?.node) {
const node = at.node;
const point = params.network().lines.find((l) => l.id === node.line)!.vertices[node.vertex];
const [sx, sy] = map.toScreen(point);
start(sx, sy);
items.push("-", ["노드 지움", () => edit.dropNode(node)]);
if (!marks.full() && !params.points().some((p) => samePoint(p, point)))
items.push(["지점으로", () => marks.place(sx, sy, map)]);
} else if (at?.segment) {
const seg = at.segment;
start(px, py);
items.push(["여기서 연도 구역 나눔", () => edit.splitAt(px, py, map)]);
items.push(["다시 긋기", () => params.respan(seg.line, seg.index, seg.index + 1)]);
} else start(px, py);
if (pin === null && !at?.node && !at?.mark && !marks.full())
items.push("-", ["지점 놓기", () => marks.place(px, py, map)]);
menu.open(menuAt[0], menuAt[1], items);
return true;
},
};
return {
layer,
mount(viewport) {
viewport.append(menu.element);
viewport.addEventListener(
"contextmenu",
(event) => {
const rect = viewport.getBoundingClientRect();
menuAt = [event.clientX - rect.left, event.clientY - rect.top];
},
true,
);
},
};
}
+186 -80
View File
@@ -19,10 +19,8 @@
import "./D03_PlanAnalysis_UI_Style.css";
import { ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { pickFiles } from "@ui/ui_template_drop";
import { askConfirm } from "@ui/ui_template_modal";
import { createProgressCircle } from "@ui/ui_template_progress";
import { createText } from "@ui/ui_template_text";
import {
createButton,
hideLoadingOverlay,
@@ -42,9 +40,12 @@ import {
fetchTerrainExtent,
savePlanNetwork,
startTerrain,
type PlaceHit,
type PlanJob,
type PlanNetworkView,
type PlanSurface,
type TerrainAim,
type TerrainStart,
} from "./D03_PlanAnalysis_Api";
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw";
@@ -56,9 +57,11 @@ import {
type PlanMapLayer,
} from "./D03_PlanAnalysis_UI_Map";
import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure";
import { createPlanMenu } from "./D03_PlanAnalysis_UI_Menu";
import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points";
import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces";
import { createPlanProps } from "./D03_PlanAnalysis_UI_Props";
import { createPlanSite } from "./D03_PlanAnalysis_UI_Site";
import {
emptyNetwork,
fromServer,
@@ -75,16 +78,26 @@ const POLL_MS = 1000;
const INTERRUPTED = "끊김(서버 다시 켜짐 등)";
/** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */
const SITE_RADIUS_M = 1000;
/** 조용히 받기가 덮는 거리 — 기준점에서 이보다 먼 지점 · 선은 안 받음(위치를 멀리 옮긴 뒤 옛 선 = 거대 받기 막음). */
const COVER_MAX_M = 10000;
/** 되돌리기 쌓는 수. */
const UNDO_MAX = 100;
/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름)은 안 읽음. */
const DRAFT_VERSION = 4;
/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름 · 위치 없음)은 안 읽음. */
const DRAFT_VERSION = 5;
/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선(되돌리기 · 캐시 · 고침 판정). */
/** 기준점(위치 칸 · 등록과 한 칸 `projects.site_*`). */
interface Site {
lon: number;
lat: number;
address: string | null;
}
/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선 + 위치(되돌리기 · 캐시 · 고침 판정). */
interface Snap {
network: PlanNetwork;
points: Vertex[];
pointNames: string[];
site: Site | null;
}
interface Draft extends Snap {
@@ -100,10 +113,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
let editable = false;
let points: Vertex[] = []; // 지점(긋기에 붙는 점 · 591-2)
let pointNames: string[] = []; // 지점 이름(596 · 597-2)
let site: Site | null = null; // 위치(캐시 · [저장] 때 정본 596-2)
let savedSite: Site | null = null;
let epsg = 0;
let yearStart = new Date().getFullYear(); // 새 선 처음 해
let network: PlanNetwork = emptyNetwork();
let savedJson = JSON.stringify({ network, points, pointNames }); // 못 읽은 채 떠남 = 고침 없음
let savedJson = JSON.stringify({ network, points, pointNames, site }); // 못 읽은 채 떠남 = 고침 없음
let undoStack: Snap[] = [];
let redoStack: Snap[] = [];
let surface: PlanSurface | null = null;
@@ -113,6 +128,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음)
let busy = false;
let failed: string | null = null;
let note = ""; // 위치 칸 알림(기준점 없음 · 못 불러옴)
let measuring = false;
let lastSaveMs: number | null = null;
@@ -127,6 +143,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
refresh();
},
});
const stepPoints = (nextPoints: Vertex[], next: PlanNetwork, nextNames = pointNames): void =>
step({ network: next, points: nextPoints, pointNames: nextNames, site }); // 지점 한 걸음 — 위치 그대로
const edit = createPlanEdit({
network: () => network,
base: () => points,
@@ -135,6 +153,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
network = normalizePieces(next, yearStart);
map.redraw();
},
commitPoints: stepPoints,
showPoints: (nextPoints, next) => {
[points, network] = [nextPoints, next];
map.redraw();
},
enabled: () => draw.mode() === "idle" && !measuring,
editable: () => editable,
onSelect: () => refresh(),
@@ -145,17 +168,30 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
editable: () => editable,
yearStart: () => yearStart,
commit,
heightAt: () => z,
epsg: () => epsg,
points: () => points,
addPoint: (at) => stepPoints([...points, at], network, [...pointNames, ""]),
commitPoints: stepPoints,
respan: draw.respan,
});
const pieces = createPlanPieces({ network: () => network, edit });
const measure = createPlanMeasure(() => z);
const pieces = createPlanPieces({
network: () => network,
edit,
editable: () => editable,
yearOptions: props.yearOptions,
commit,
});
const measure = createPlanMeasure(
() => z,
() => network,
);
const marks = createPlanPoints({
points: () => points,
network: () => network,
names: () => pointNames,
epsg: () => epsg,
draw,
commit: (nextPoints, nextNames, next) =>
step({ network: next, points: nextPoints, pointNames: nextNames }),
commit: (nextPoints, nextNames, next) => stepPoints(nextPoints, next, nextNames),
center: () => viewMid,
show: (nextPoints, next) => {
[points, network] = [nextPoints, next];
@@ -164,6 +200,15 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
idle: () => draw.mode() === "idle" && !measuring,
editable: () => editable,
});
const menu = createPlanMenu({
network: () => network,
points: () => points,
marks,
edit,
draw,
editable: () => editable,
respan: draw.respan,
});
// 지도 보기 가운데 — 그릴 때마다 작업 좌표로(지점 [추가] 자리 · 보기만 옮김 = 안 받음 596-4)
const viewWatch: PlanMapLayer = {
draw(context, screen) {
@@ -191,30 +236,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
main.className = "d03-plan";
main.append(map.root);
const terrainText = createText("", "muted");
terrainText.classList.add("d03-plan__hint");
const retryButton = createButton({
label: "다시 받기",
variant: "filled",
size: 28,
onClick: () => {
const siteBox = createPlanSite({
onPick: (hit) => void pickSite(hit),
onRetry: () => {
[failed, lastTried, ready] = [null, "", true];
void ensureCovered();
void ensureCovered(true);
},
onUpload: (files) => void uploadRoads(files),
});
const terrainCard = createPanelCard("지형", false);
const roadButton = createButton({
label: "임도망 올리기",
variant: "ghost",
size: 28,
onClick: () =>
pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, (files) => {
void backdrop.uploadRoads(files);
}),
});
roadButton.title =
"임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)";
terrainCard.append(terrainText, retryButton, roadButton);
const siteCard = createPanelCard("위치", false);
siteCard.append(...siteBox.parts);
const pointCard = createPanelCard("지점", false);
pointCard.append(marks.panel);
const drawCard = createPanelCard("그리기", false);
@@ -232,7 +263,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
actions.append(saveButton, resetButton);
const panel = document.createElement("div");
panel.className = "ui-sidebar-panel d03-plan__panel";
panel.append(terrainCard, pointCard, drawCard, pieceCard, actions);
panel.append(siteCard, pointCard, drawCard, pieceCard, actions);
attachCollapsible(panel);
// 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 고친 선이 있으면 공용 물음 [취소][폐기][저장](572-3)
mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, {
@@ -245,7 +276,27 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout");
const allVertices = (): Vertex[] => network.lines.flatMap((line) => line.vertices);
const snap = (): Snap => ({ network, points, pointNames });
const snap = (): Snap => ({ network, points, pointNames, site });
/** 위치를 옮기고 [저장] 전 — 받기 · 범위 물음에 새 기준점을 실음. */
const moved = (): TerrainAim["center"] =>
site && JSON.stringify(site) !== JSON.stringify(savedSite)
? { lon: site.lon, lat: site.lat }
: null;
/** 기준점 둘레 ±1 km(작업 좌표) — 처음 맞춤 · 위치 옮김 · 임도망 맞춤. */
const around = (at: Site | null): Vertex[] => {
const tm = tmParams(epsg);
if (!at || !tm) return [];
const [x, y] = lonLatToTm(tm, at.lon, at.lat);
return [
[x - SITE_RADIUS_M, y - SITE_RADIUS_M],
[x + SITE_RADIUS_M, y + SITE_RADIUS_M],
];
};
const fitTo = (box: Vertex[]): void => {
if (!box.length) return;
map.update({ fit: box });
map.fit();
};
const dirty = (): boolean => JSON.stringify(snap()) !== savedJson;
const keepDraft = (): void =>
dirty()
@@ -256,10 +307,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
function step(next: Snap): void {
undoStack = [...undoStack.slice(1 - UNDO_MAX), snap()];
redoStack = [];
[network, points, pointNames] = [
[network, points, pointNames, site] = [
normalizePieces(next.network, yearStart),
next.points,
next.pointNames,
next.site,
];
keepDraft();
refresh();
@@ -267,14 +319,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
}
function commit(next: PlanNetwork): void {
step({ network: next, points, pointNames });
step({ network: next, points, pointNames, site });
}
function undo(): void {
const last = undoStack.pop();
if (!last) return;
redoStack.push(snap());
({ network, points, pointNames } = last);
({ network, points, pointNames, site } = last);
keepDraft();
refresh();
}
@@ -283,7 +335,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const next = redoStack.pop();
if (!next) return;
undoStack.push(snap());
({ network, points, pointNames } = next);
({ network, points, pointNames, site } = next);
keepDraft();
refresh();
}
@@ -297,7 +349,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
refresh();
}
/** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림 · 우클릭 메뉴(`marks.pins`)는 맨 끝(맨 위). */
/** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림(`marks.pins`) · 우클릭 메뉴(`menu.layer`)는 맨 끝(맨 위). */
const layerList = (): PlanMapLayer[] => [
...backdrop.layers,
draw.layer,
@@ -306,20 +358,23 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
...(measuring ? [measure.layer] : []),
viewWatch,
marks.pins,
menu.layer,
];
/** 점들이 받은 범위 안인가. */
const inside = (list: Vertex[]): boolean =>
!!have &&
list.every(([x, y]) => x >= have![0] && x <= have![2] && y >= have![1] && y <= have![3]);
const outside = (): boolean => !!have && !inside([...points, ...allVertices()]);
/** 조용히 받기(요구 453) — 지점 · 선이 받은 범위 밖이면 이어 받음(지도 보기만 옮김 = 안 받음 596-4) ·
* 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */
async function ensureCovered(): Promise<void> {
async function ensureCovered(again = false): Promise<void> {
if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return;
const extra = [...points, ...allVertices()];
if (inside(extra)) return; // 받을 때 서버가 여유를 더함
const [lo, hi] = around(site);
const near = ([x, y]: Vertex): boolean =>
!lo || Math.hypot(x - (lo[0] + hi[0]) / 2, y - (lo[1] + hi[1]) / 2) <= COVER_MAX_M;
const extra = [...points, ...allVertices()].filter(near);
if (!again && inside(extra)) return; // 받을 때 서버가 여유를 더함
const key = JSON.stringify([have, extra]);
if (key === lastTried) return;
lastTried = key;
@@ -340,34 +395,88 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
props.render();
pieces.render();
marks.render();
retryButton.hidden = !editable || busy || !failed;
roadButton.hidden = !editable;
if (!busy)
terrainText.textContent = failed
? `지형 받기 실패 — ${failed}`
: !surface
? editable
? "받은 지형 없음 — 기준점 둘레를 받습니다(길이 · 기울기는 지형을 받은 뒤)."
: "받은 지형 없음"
: outside()
? "지점 또는 그은 선이 받은 지형 밖 — 이어 받습니다."
: "받은 지형 안";
siteBox.render({
address: site?.address ?? null,
note: failed ? `지형 받기 실패 — ${failed}` : note,
retry: !busy && !!failed,
editable,
});
saveButton.hidden = resetButton.hidden = !editable;
saveButton.disabled = busy || !dirty();
}
async function download(extra: Vertex[]): Promise<void> {
/** 받기 — 멂(받은 범위에서 5 km 넘음) · 다른 TM 띠 = 물음 한 번 뒤 옛 범위 버리고 새로(`reset`) ·
* 아니오 = 안 받음(거짓). 이미 도는 일(다른 창)이면 그것을 지켜봄. */
async function download(extra: Vertex[]): Promise<boolean> {
busy = true; // 요청이 가는 동안 다른 받기를 막음(지켜보기가 이어 받음)
const where: TerrainAim = { center: moved() };
try {
await startTerrain(projectId, null, extra);
let answer: TerrainStart = await startTerrain(projectId, null, extra, where);
if (answer.status === "far" || answer.status === "band") {
const band = answer.status === "band";
const ok = await askConfirm({
title: band ? "다른 좌표 띠로 옮김" : "멀리 옮김",
message: band ? `${answer.message} 그은 선 · 지점을 지웁니다.` : answer.message,
okLabel: "새로 받기",
danger: band,
});
if (!ok) {
busy = false;
refresh();
return false;
}
if (answer.status === "band") clearForBand(answer.center_epsg);
answer = await startTerrain(projectId, null, [], { ...where, reset: true });
}
} catch (error) {
// 이미 도는 일(다른 창)이면 그것을 지켜봄
if (!/이미 돌고/.test(errorText(error))) {
busy = false;
return void showToast(errorText(error), "error");
refresh();
showToast(errorText(error), "error");
return false;
}
}
await follow();
return true;
}
/** 다른 TM 띠 — 작업 좌표가 바뀜 · 선 · 지점을 걷고 되돌리기를 비움(옛 좌표라 못 되돌림). */
function clearForBand(next: number): void {
epsg = next;
[network, points, pointNames] = [emptyNetwork(), [], []];
[undoStack, redoStack, have] = [[], [], null];
map.update({ epsg, fit: around(site) }); // 맞춤 상자도 새 띠 좌표로
map.fit();
keepDraft();
}
/** 위치 칸 고름 — 기준점 캐시(한 걸음 · [저장] 때 정본) → 지도 이동 → 둘레 받기(멂 · 띠 = 물음 · 아니오 = 되돌림). */
async function pickSite(hit: PlaceHit): Promise<void> {
if (busy) return void showToast("지형을 받는 중 — 끝난 뒤 다시 고르세요.", "info");
epsg ||= hit.lon < 126 ? 5185 : hit.lon < 128 ? 5186 : 5187; // 기준점 없던 프로젝트(서버 띠 규칙)
[note, failed, lastTried, ready] = ["", null, "", false]; // 걸음의 조용히 받기는 막고 아래에서 한 번
step({ ...snap(), site: { lon: hit.lon, lat: hit.lat, address: hit.address || hit.title } });
ready = true;
fitTo(around(site));
if (await download([])) return; // 새 자리 둘레(center) — 지점 · 선은 끝난 뒤 조용히 받기가
undo(); // 물음에 아니오 · 받기 못 함 = 위치 그대로
redoStack.pop();
fitTo(around(site));
}
/** 임도망 올리기 — 지도 맞춤 = 주소지 둘레 ∪ 잘린 임도 상자(없으면 주소지). */
async function uploadRoads(files: File[]): Promise<void> {
const info = await backdrop.uploadRoads(files);
const tm = tmParams(epsg);
if (!info) return;
const roads: Vertex[] =
tm && info.bounds
? [
lonLatToTm(tm, info.bounds[0], info.bounds[1]),
lonLatToTm(tm, info.bounds[2], info.bounds[3]),
]
: [];
fitTo([...around(site), ...roads]);
}
/** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */
@@ -429,14 +538,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
? fromServer(server.lines, server.marks, server.pieces, yearStart)
: emptyNetwork();
points = view.base.points.map((p): Vertex => [p.x, p.y]);
pointNames = view.base.points.map(() => ""); // 594-1 이름 칸이 들어오면 `p.name ?? ""`
pointNames = view.base.points.map((p) => p.name ?? "");
site = savedSite = view.base.site;
if (view.base.epsg) epsg = view.base.epsg;
savedJson = JSON.stringify(snap());
}
function serverBody(): Parameters<typeof savePlanNetwork>[1] {
const { lines, pieces } = network;
return { epsg, points, lines, marks: marksOf(network), pieces };
const body = { epsg, points, point_names: pointNames, lines, marks: marksOf(network), pieces };
return moved() && site ? { ...body, site } : body;
}
/** [저장] — 서버가 다시 셈 · 실패면 그대로(캐시 남음). 참 = 저장됨. */
@@ -473,7 +584,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
draw.setMode("idle");
showLoadingOverlay();
try {
const body = { epsg, points, lines: [], marks: [], pieces: [] };
const body = { epsg, points, point_names: pointNames, lines: [], marks: [], pieces: [] };
takeServer(await savePlanNetwork(projectId, body));
clearState("d03-plan", projectId);
undoStack = [];
@@ -506,6 +617,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
base: () => points,
have: () => have,
busy: () => busy,
site: () => site,
pickSite,
lineNames: () => lineNames(network.lines),
screenOf: map.screenOf,
screenOfLonLat: map.screenOfLonLat,
@@ -546,32 +659,24 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
};
window.addEventListener("keydown", onKey);
map.setLayers(layerList());
marks.mount(map.root);
menu.mount(map.root);
try {
editable = !!(await fetchSessionUser())?.is_forest_service;
draw.setEditable(editable);
const view = await fetchPlanNetwork(projectId);
const { epsg: workEpsg, site } = view.base;
if (!workEpsg) {
terrainText.textContent = "기준점이 없습니다 — 예정노선 등록에서 정하세요.";
return;
if (!view.base.epsg) {
note = "기준점이 없습니다 — 주소를 찾아 정하세요.";
ready = true;
return refresh();
}
epsg = workEpsg;
epsg = view.base.epsg;
takeServer(view);
const draft = readState<Draft>("d03-plan", projectId);
if (draft?.version === DRAFT_VERSION && draft.network && Array.isArray(draft.points))
({ network, points, pointNames } = draft);
const tm = tmParams(epsg);
const siteXy = site && tm ? lonLatToTm(tm, site.lon, site.lat) : null;
const around: Vertex[] = siteXy
? [
[siteXy[0] - SITE_RADIUS_M, siteXy[1] - SITE_RADIUS_M],
[siteXy[0] + SITE_RADIUS_M, siteXy[1] + SITE_RADIUS_M],
]
: [];
({ network, points, pointNames, site } = draft);
const drawn = [...points, ...allVertices()];
map.update({ fit: drawn.length ? drawn : around, epsg });
map.update({ fit: drawn.length && !moved() ? drawn : around(site), epsg });
refresh();
await loadSurface();
refresh();
@@ -582,12 +687,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
await follow();
} else if (editable && !job.error) {
// 들어올 때 받은 범위가 기준점 둘레 · 지점을 못 덮으면 바로 받기(서버가 셈 · 실패 뒤엔 [다시 받기])
const extent = await fetchTerrainExtent(projectId, null, []);
const extent = await fetchTerrainExtent(projectId, null, [], moved());
ready = true;
if (!extent.covered) await download([]);
else await ensureCovered();
}
} catch (error) {
terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`;
note = `불러오지 못했습니다 — ${errorText(error)}`;
refresh();
}
}
+173 -45
View File
@@ -1,13 +1,26 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Pieces.ts
* D03 좌측 「구역 · 연도」 목록 몸(PLAN 597) — 해 줄(같은 해) · 선 머리 줄(선) · 구간 줄(구간).
* 속성 판(`UI_Props.ts`)에서 떼어냄 — 목록 = 보기 + 고르기.
* D03 좌측 「구역 · 연도」 목록 몸(PLAN 597 · 요구 463 14) — 구역 = 그은 꺾은선 하나(본선 · 갈래).
* 해 줄(같은 해) · 구역 줄(색 띠 = 지도 선과 같은 `routeColor(차례)` · 이름 칸 · 연도 고름 · 지움) ·
* 구간 줄(측점 글 작게 · 연도 고름). 누름 = 고름 · 해 고침 · 이름 · 지움은 한 걸음(되돌리기 · 캐시 = 페이지).
* 연도 고름은 속성 판과 같은 셈(`setLineYear` · `setPiecesYear`) · 보기만 계정 = 값만.
* ========================================================================== */
import { createText } from "@ui/ui_template_text";
import { createIconButton } from "@ui/ui_template_button";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { createListRow } from "@ui/ui_template_list_row";
import { askConfirm } from "@ui/ui_template_modal";
import { routeColor } from "@ui/ui_template_palette";
import { stationLabel } from "../common_util/common_util_station";
import { lineLength } from "./logic/D03_PlanAnalysis_Draw";
import { lineNames, type PlanNetwork } from "./logic/D03_PlanAnalysis_Plan";
import {
lineNames,
nameError,
setLineYear,
setPiecesYear,
type PlanNetwork,
} from "./logic/D03_PlanAnalysis_Plan";
import { setLineName } from "./logic/D03_PlanAnalysis_PointEdit";
import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit";
import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
@@ -17,6 +30,10 @@ const meters = (m: number): string => `${m.toFixed(1)} m`;
export interface PlanPiecesParams {
network: () => PlanNetwork;
edit: PlanEdit;
editable: () => boolean;
/** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */
yearOptions: () => Array<{ value: string; text: string }>;
commit: (next: PlanNetwork) => void;
}
export interface PlanPieces {
@@ -25,79 +42,190 @@ export interface PlanPieces {
render: () => void;
}
/** 좌측 줄 — 누름 = 고름(div · 열쇠 Enter 도). */
const pickRow = (row: HTMLElement, onPick: () => void): HTMLElement => {
row.tabIndex = 0;
row.setAttribute("role", "button");
row.addEventListener("click", onPick);
row.addEventListener("keydown", (event) => event.key === "Enter" && onPick());
return row;
};
export function createPlanPieces(params: PlanPiecesParams): PlanPieces {
const { edit } = params;
const list = document.createElement("div");
list.className = "d03-plan__pieces";
let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기)
let syncs: Array<() => void> = [];
/** 줄 안 칸 눌림은 줄 고름으로 안 번지게. */
const isControl = (event: Event): boolean =>
!!(event.target as Element).closest("input, select, button, .ui-list-row__trailing");
/** 줄 하나 — 누름 = 고름(열쇠 Enter 도) · 줄 안 칸은 제외. */
const pickable = (row: HTMLElement, onPick: () => void): HTMLElement => {
row.tabIndex = 0;
row.addEventListener("click", (event) => isControl(event) || onPick());
row.addEventListener("keydown", (event) => {
if (event.key === "Enter" && event.target === row) onPick();
});
return row;
};
/** 연도 고름 칸 — 값이 섞이면 「여러 해」. */
function yearSelect(
years: () => number[],
label: string,
pick: (year: number) => void,
): { root: HTMLElement; sync: () => void } {
const field = createSelectField({
options: [],
size: 24,
ariaLabel: label,
disabled: !params.editable(),
onChange: (value) => value && pick(Number(value)),
});
const sync = (): void => {
const set = new Set(years());
const options = params.yearOptions();
field.setOptions(
set.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options,
set.size > 1 ? "" : String([...set][0]),
);
};
sync();
return { root: field.root, sync };
}
function render(): void {
const network = params.network();
const names = lineNames(network.lines);
const picked = new Set(edit.selected()?.pieces ?? []);
const scope = edit.selected()?.scope;
const editable = params.editable();
const key = JSON.stringify([
network.lines.map((line) => line.id),
network.pieces.map((piece) => [piece.id, piece.line]),
[...picked],
scope,
editable,
params.yearOptions().length,
]);
if (key === shown) {
syncs.forEach((sync) => sync());
return;
}
shown = key;
syncs = [];
const rows: HTMLElement[] = [];
const byYear = new Map<number, typeof network.pieces>();
for (const piece of network.pieces)
byYear.set(piece.year, [...(byYear.get(piece.year) ?? []), piece]);
for (const [year, pieces] of [...byYear].sort(([a], [b]) => a - b)) {
const row = document.createElement("div");
row.className = "d03-plan__piece d03-plan__piece-year";
row.dataset.year = String(year);
row.classList.toggle(
"is-selected",
scope === "year" && pieces.every((p) => picked.has(p.id)),
);
const length = pieces.reduce((sum, p) => sum + p.to_m - p.from_m, 0);
row.append(createText(`${year}년 · 구간 ${pieces.length} · ${meters(length)}`, "muted"));
rows.push(
pickRow(row, () => {
const row = createListRow({
label: `${year}년`,
sub: `구간 ${pieces.length} · ${meters(length)}`,
size: 28,
active: scope === "year" && pieces.every((p) => picked.has(p.id)),
onClick: () => {
edit.select([pieces[0].id]);
edit.setScope("year");
}),
);
},
});
row.dataset.year = String(year);
rows.push(row);
}
network.lines.forEach((line, index) => {
const pieces = network.pieces.filter((piece) => piece.line === line.id);
if (!pieces.length) return;
const head = document.createElement("div");
head.className = "d03-plan__piece d03-plan__piece-head";
head.dataset.line = line.id;
head.classList.toggle(
"is-selected",
scope === "line" && pieces.every((p) => picked.has(p.id)),
const lineNow = (): (typeof network.lines)[number] =>
params.network().lines.find((entry) => entry.id === line.id) ?? line;
const piecesNow = (): typeof pieces =>
params.network().pieces.filter((piece) => piece.line === line.id);
const nameField = createInputField({ size: 24, placeholder: names[index] });
nameField.input.value = line.name ?? "";
nameField.input.setAttribute("aria-label", "구역 이름");
nameField.input.dataset.role = "line-name";
nameField.input.disabled = !editable;
nameField.input.addEventListener("input", () =>
nameField.setError(nameError(nameField.input.value) ?? undefined),
);
head.append(createText(`${names[index]} · ${meters(lineLength(line.vertices))}`, "muted"));
nameField.input.addEventListener("change", () => {
const error = nameError(nameField.input.value);
nameField.setError(error ?? undefined);
const text = nameField.input.value.trim();
if (!error && text !== (lineNow().name ?? ""))
params.commit(setLineName(params.network(), line.id, text));
});
const year = yearSelect(
() => piecesNow().map((piece) => piece.year),
"구역 연도",
(value) => params.commit(setLineYear(params.network(), line.id, value)),
);
year.root.dataset.role = "line-year";
const drop = createIconButton({
icon: "trash",
title: "구역 지움",
size: 24,
onClick: async () => {
const ok = await askConfirm({
message: `「${lineNames(params.network().lines)[index]}」 구역을 지울까요?`,
okLabel: "지움",
danger: true,
});
if (!ok) return;
edit.dropRegion(line.id);
},
});
drop.dataset.role = "line-drop";
drop.hidden = !editable;
const tail = document.createElement("span");
tail.className = "d03-plan__line-tail";
tail.append(year.root, drop);
const head = createListRow({
label: "",
sub: `구간 ${pieces.length} · ${meters(lineLength(line.vertices))}`,
stripe: routeColor(index),
size: 28,
trailing: tail,
active: scope === "line" && pieces.every((p) => picked.has(p.id)),
});
head.querySelector(".ui-list-row__label")?.replaceChildren(nameField.root);
head.dataset.line = line.id;
rows.push(
pickRow(head, () => {
pickable(head, () => {
edit.select([pieces[0].id]);
edit.setScope("line");
}),
);
syncs.push(() => {
year.sync();
nameField.input.placeholder = lineNames(params.network().lines)[index] ?? "";
if (document.activeElement !== nameField.input)
nameField.input.value = lineNow().name ?? "";
const sub = head.querySelector(".ui-list-row__sub");
if (sub)
sub.textContent = `구간 ${piecesNow().length} · ${meters(lineLength(lineNow().vertices))}`;
});
for (const piece of pieces) {
const row = document.createElement("div");
row.className = "d03-plan__piece";
row.classList.toggle("is-selected", picked.has(piece.id));
row.dataset.piece = piece.id;
row.append(
createText(
`${station(piece.from_m)} ~ ${station(piece.to_m)} (${meters(piece.to_m - piece.from_m)})`,
"muted",
),
createText(`${piece.year}년`, "muted"),
const pieceNow = (): (typeof pieces)[number] =>
params.network().pieces.find((entry) => entry.id === piece.id) ?? piece;
const pieceYear = yearSelect(
() => [pieceNow().year],
"구간 연도",
(value) => params.commit(setPiecesYear(params.network(), [piece.id], value)),
);
rows.push(pickRow(row, () => edit.select([piece.id])));
const row = createListRow({
label: `${station(piece.from_m)} ~ ${station(piece.to_m)}`,
sub: meters(piece.to_m - piece.from_m),
size: 28,
trailing: pieceYear.root,
active: picked.has(piece.id),
});
row.classList.add("d03-plan__piece-row");
row.dataset.piece = piece.id;
rows.push(pickable(row, () => edit.select([piece.id])));
syncs.push(pieceYear.sync);
}
});
if (!rows.length) rows.push(createText("선을 그으면 구역이 생깁니다.", "muted"));
list.replaceChildren(...rows);
}
+186 -77
View File
@@ -1,27 +1,39 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Points.ts
* D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로.
* 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 「지점 N」 · 🗑) · 핀 = 늘 맨 위(`pointLayer`).
* 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지).
* 지도 우클릭 = 메뉴(부품 상자 `createMapContextMenu`) — 핀: 여기서 긋기 · 지움 / 그 밖: 긋기 시작(빈 땅 = 새 본선 ·
* 선 위 = 갈래 · 선 끝 = 이어 긋기) · 지점 놓기(꺾임점 둘레면 그 점에) · 긋는 중 = 긋기 끝.
* 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 우클릭 메뉴 · 겹 맨 끝 = 맨 위 ·
* 꺾임점 · 마크 오른쪽 누름 = 고르기 겹이 먼저 지움).
* 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 이름 · 🗑) · 핀 = 늘 맨 위(`pointLayer`).
* [추가] = 지도 보기 가운데에 바로 놓음(597-2) · 줄 누름 = 그 줄만 펼침(이름 · 경도 · 위도 — 칸 떠남 · Enter 가 한 걸음).
* 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 노드 12 px 안에 놓으면 그 노드에 붙음(598-3) ·
* 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지).
* 지도 우클릭 메뉴 = `D03_PlanAnalysis_UI_Menu`(핀 집기 · 놓기 · 지우기를 여기서 빌림).
* 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 겹 맨 끝 = 맨 위).
* ========================================================================== */
import { createIconButton } from "@ui/ui_template_button";
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { createButton } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { createListRow } from "@ui/ui_template_list_row";
import { pointColor } from "@ui/ui_template_palette";
import { createText } from "@ui/ui_template_text";
import { moveNodesAt, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan";
import {
moveNodesAt,
nameError,
samePoint,
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import {
addPoint,
MAX_POINTS,
movePointTo,
parseLonLat,
pointLabel,
renamePoint,
type PointsState,
} from "./logic/D03_PlanAnalysis_PointEdit";
import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm";
import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw";
import { pointLayer, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */
const MAX_POINTS = 40;
/** 핀 머리 = 꼭지 위 px(`pointLayer` 그림과 같음). */
const PIN_HEAD_PX = 18;
@@ -31,7 +43,6 @@ export interface PlanPointsParams {
names: () => string[];
network: () => PlanNetwork;
epsg: () => number;
draw: PlanDraw;
/** 한 걸음 — 지점 · 이름 · 노선 함께. */
commit: (points: Vertex[], names: string[], network: PlanNetwork) => void;
/** 지도 보기 가운데(작업 좌표 · 못 잼 = null) — [추가] 자리. */
@@ -49,19 +60,33 @@ export interface PlanPoints {
input: PlanMapLayer;
pins: PlanMapLayer;
render: () => void;
/** 메뉴를 띄울 지도 그릇(우클릭 자리 = 그릇 기준). */
mount: (viewport: HTMLElement) => void;
/** 우클릭 메뉴가 빌림 — 핀 집기 · 지점 놓기(노드 둘레면 그 노드) · 지우기 · 이름 · 더 놓을 수 있나. */
pinAt: (px: number, py: number, map: ScreenMap) => number | null;
place: (px: number, py: number, map: ScreenMap) => void;
remove: (i: number) => void;
name: (i: number) => string;
full: () => boolean;
}
export function createPlanPoints(params: PlanPointsParams): PlanPoints {
const list = document.createElement("div");
list.className = "d03-plan__points";
const hint = createText("", "muted");
hint.classList.add("d03-plan__hint");
const addButton = createButton({
label: "추가",
iconStart: "plus",
variant: "ghost",
size: 24,
onClick: addAtCenter,
});
addButton.dataset.role = "points-add";
const head = document.createElement("div");
head.className = "d03-plan__points-head";
head.append(addButton);
const panel = document.createElement("div");
panel.append(list, hint);
const menu = createMapContextMenu("d03-plan");
let menuAt: [number, number] = [0, 0];
panel.append(head, list);
let openIndex: number | null = null; // 펼친 줄(하나만)
let syncEditor: (() => void) | null = null;
let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기)
let drag: {
index: number;
points: Vertex[];
@@ -69,7 +94,14 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
last: [Vertex[], PlanNetwork] | null;
} | null = null;
const name = (i: number): string => `지점 ${i + 1}`;
const name = (i: number): string => pointLabel(params.names(), i);
const state = (): PointsState => ({
points: params.points(),
names: params.names(),
network: params.network(),
});
const commitState = (next: PointsState): void =>
params.commit(next.points, next.names, next.network);
/** 핀 집기 — 꼭지 · 머리(보이는 화면 위쪽) 둘레 · 뒤 지점(위에 그려짐) 먼저. */
function pinAt(px: number, py: number, map: ScreenMap): number | null {
@@ -88,6 +120,7 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
}
function remove(i: number): void {
openIndex = null;
params.commit(
params.points().filter((_, k) => k !== i),
params.names().filter((_, k) => k !== i),
@@ -95,61 +128,40 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
);
}
/** 지점 놓기 — 꺾임점 둘레(SNAP_PX)면 그 점 자리(노드에 붙음). */
function place(px: number, py: number, map: ScreenMap): void {
if (params.points().length >= MAX_POINTS) return;
/** 붙음(598-3) — 화면 px 둘레(SNAP_PX) 가장 가까운 노드 자리 · `skip` 자리 노드(같이 가는 것) 빼고. */
function nodeNear(px: number, py: number, map: ScreenMap, skip?: Vertex): Vertex | null {
const node = params
.network()
.lines.flatMap((line) => line.vertices)
.filter((point) => !skip || !samePoint(point, skip))
.map((point) => ({ point, gap: screenGap(map, point, px, py) }))
.filter((item) => item.gap <= SNAP_PX)
.sort((a, b) => a.gap - b.gap)[0];
const at = node?.point ?? map.toMetric(px, py);
return node ? node.point : null;
}
/** 지점 놓기 — 노드 둘레면 그 노드 자리(노드에 붙음). */
function place(px: number, py: number, map: ScreenMap): void {
if (params.points().length >= MAX_POINTS) return;
const at = nodeNear(px, py, map) ?? map.toMetric(px, py);
params.commit([...params.points(), [at[0], at[1]]], [...params.names(), ""], params.network());
}
function openMenu(px: number, py: number, map: ScreenMap): boolean {
if (!params.editable()) return false;
const draw = params.draw;
const pin = pinAt(px, py, map);
const items: MapContextMenuItem[] = [];
if (draw.mode() === "draw" && draw.stroke())
items.push(["긋기 끝", () => draw.setMode("idle")]);
else if (pin !== null) {
const [sx, sy] = map.toScreen(params.points()[pin]); // 핀 머리를 눌러도 지점 자리에서
items.push([`${name(pin)}에서 긋기 시작`, () => draw.startAt(sx, sy, map)]);
items.push([`${name(pin)} 지우기`, () => remove(pin)]);
} else {
const label = {
tip: "이어 긋기",
point: "지점에서 긋기 시작",
line: "갈래 시작",
empty: "여기서 긋기 시작",
}[draw.aim(px, py, map)];
items.push([label, () => draw.startAt(px, py, map)]);
}
if (pin === null && params.points().length < MAX_POINTS)
items.push(["지점 놓기", () => place(px, py, map)]);
menu.open(menuAt[0], menuAt[1], items);
return true;
}
const pins = pointLayer(params.points);
pins.press = (px, py, map, button) => (button === 2 ? openMenu(px, py, map) : false);
const pins = pointLayer(params.points, params.names);
const input: PlanMapLayer = {
draw: () => undefined,
press(px, py, map, button) {
if (button !== 0 || !params.editable() || !params.idle()) return false;
const index = pinAt(px, py, map);
if (index === null) return false;
if (index === null || nodeNear(px, py, map) !== null) return false; // 노드 위 = 고르기 겹(노드 고름 · 끌면 지점 같이)
drag = { index, points: params.points(), network: params.network(), last: null };
return true;
},
hover(px, py, map) {
if (!drag) return;
const to = map.toMetric(px, py);
const from = drag.points[drag.index];
const to = nodeNear(px, py, map, from) ?? map.toMetric(px, py);
const points = drag.points.map((p, k): Vertex => (k === drag!.index ? to : p));
drag.last = [points, moveNodesAt(drag.network, from, to)];
params.show(...drag.last);
@@ -163,12 +175,101 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
},
};
/** [추가] — 지도 보기 가운데에 바로 놓고 그 줄을 펼침. */
function addAtCenter(): void {
const at = params.center();
if (!at || !params.editable() || params.points().length >= MAX_POINTS) return;
openIndex = params.points().length;
commitState(addPoint(state(), at));
}
/** 펼친 줄 — 이름 · 경도 · 위도 칸(Enter · 칸 떠남 = 한 걸음 · 잘못 쓴 값은 까닭만 보이고 안 받음). */
function editorOf(i: number): { root: HTMLElement; sync: () => void } {
const tm = tmParams(params.epsg());
const editable = params.editable();
const field = (label: string, role: string, placeholder?: string) => {
const f = createInputField({ label, size: 24, placeholder });
f.input.dataset.role = role;
f.input.disabled = !editable || (role !== "point-name" && !tm);
return f;
};
const nameField = field("이름", "point-name", `지점 ${i + 1}`);
const lonField = field("경도", "point-lon");
const latField = field("위도", "point-lat");
const lonLatNow = (): [number, number] | null => {
const p = params.points()[i];
return tm && p ? tmToLonLat(tm, p[0], p[1]) : null;
};
const sync = (): void => {
const set = (f: ReturnType<typeof field>, text: string): void => {
if (document.activeElement !== f.input) f.input.value = text;
};
set(nameField, params.names()[i] ?? "");
const ll = lonLatNow();
set(lonField, ll ? ll[0].toFixed(6) : "");
set(latField, ll ? ll[1].toFixed(6) : "");
};
nameField.input.addEventListener("input", () =>
nameField.setError(nameError(nameField.input.value.trim()) ?? undefined),
);
nameField.input.addEventListener("change", () => {
const text = nameField.input.value.trim();
const error = nameError(text);
nameField.setError(error ?? undefined);
if (!error && text !== (params.names()[i] ?? "")) commitState(renamePoint(state(), i, text));
});
const moved = (): void => {
const parsed = parseLonLat(lonField.input.value, latField.input.value);
const bad = typeof parsed === "string";
lonField.setError(bad && parsed.startsWith("경도") ? parsed : undefined);
latField.setError(bad && parsed.startsWith("위도") ? parsed : undefined);
if (bad || !tm) return;
const now = lonLatNow();
if (
now &&
now[0].toFixed(6) === parsed[0].toFixed(6) &&
now[1].toFixed(6) === parsed[1].toFixed(6)
)
return;
commitState(movePointTo(state(), i, tm, parsed[0], parsed[1]));
};
lonField.input.addEventListener("change", moved);
latField.input.addEventListener("change", moved);
const root = document.createElement("div");
root.className = "d03-plan__point-edit";
root.append(nameField.root, lonField.root, latField.root);
sync();
return { root, sync };
}
function render(): void {
const tm = tmParams(params.epsg());
const editable = params.editable();
const points = params.points();
if (openIndex !== null && openIndex >= points.length) openIndex = null;
addButton.hidden = !editable;
addButton.disabled = points.length >= MAX_POINTS || !params.center();
const key = `${points.length}|${openIndex}|${editable}|${tm ? 1 : 0}`;
const lonLatOf = (i: number): string | undefined => {
const ll = tm ? tmToLonLat(tm, points[i][0], points[i][1]) : null;
return ll ? `${ll[0].toFixed(6)}, ${ll[1].toFixed(6)}` : undefined;
};
if (key === shown) {
// 틀 그대로 — 이름 · 좌표 글과 펼친 칸 값만 갈아 끼움
points.forEach((_, i) => {
const row = list.children[i]?.querySelector(".ui-list-row");
const label = row?.querySelector(".ui-list-row__label");
const sub = row?.querySelector(".ui-list-row__sub");
if (label) label.textContent = name(i);
if (sub) sub.textContent = lonLatOf(i) ?? "";
});
syncEditor?.();
return;
}
shown = key;
syncEditor = null;
list.replaceChildren(
...params.points().map((point, i) => {
const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null;
...points.map((_, i) => {
const drop = createIconButton({
icon: "trash",
title: `${name(i)} 지우기`,
@@ -176,22 +277,36 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
onClick: () => remove(i),
});
drop.hidden = !editable;
return createListRow({
const row = createListRow({
label: name(i),
sub: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : undefined,
sub: lonLatOf(i),
stripe: pointColor(i),
size: 28,
trailing: drop,
active: i === openIndex,
});
row.dataset.point = String(i);
row.tabIndex = 0;
const toggle = (): void => {
openIndex = openIndex === i ? null : i;
render();
};
row.addEventListener("click", (event) => {
if (!(event.target as Element).closest(".ui-list-row__trailing")) toggle();
});
row.addEventListener("keydown", (event) => {
if (event.key === "Enter" && event.target === row) toggle();
});
const item = document.createElement("div");
item.append(row);
if (i === openIndex) {
const editor = editorOf(i);
syncEditor = editor.sync;
item.append(editor.root);
}
return item;
}),
);
hint.textContent = !editable
? params.points().length
? "보기만 — 지점은 산림청이 놓습니다."
: "지점 없음"
: params.points().length
? "핀을 끌면 그 자리 노선 점도 같이 옮깁니다 · 지도 우클릭 「지점 놓기」로 더함."
: "지도 우클릭 「지점 놓기」 — 긋기가 붙는 점(없어도 우클릭으로 긋기 시작).";
}
return {
@@ -199,16 +314,10 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
input,
pins,
render,
mount(viewport) {
viewport.append(menu.element);
viewport.addEventListener(
"contextmenu",
(event) => {
const rect = viewport.getBoundingClientRect();
menuAt = [event.clientX - rect.left, event.clientY - rect.top];
},
true,
);
},
pinAt,
place,
remove,
name,
full: () => params.points().length >= MAX_POINTS,
};
}
+253 -86
View File
@@ -1,30 +1,55 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Props.ts
* D03 고른 구간 속성 판 · 좌측 「구역 · 연도」 목록(PLAN 585-2 · 요구서 445 A4) — 오토캐드 특성 창 꼴.
* D03 고름 속성 판(PLAN 585-2 · 598 · 요구 463 13·16 · 11) — 오토캐드 특성 창 꼴 · 판 하나가 고름 꼴마다 몸을 바꿈.
*
* 속성 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 —
* 머리 「구간 N · 선 M · L m」 · ✕ = 풂 · 넓이 [구간][선][같은 해] · 연도(모두 같으면 그 해 · 다르면 「여러 해」 →
* 고르면 고른 구간 모두 그 해 = 되돌리기 한 걸음) · 고른 구간 줄(누름 = 그 구간만) ·
* [방향 바꾸기] [갈래 시점 지정] [선 지움](선 하나일 때) · [마크 지움](마크 고름) · 보기만 계정 = 값만.
* 측점 방향(585-3 · 요구 446 3) = 사용자가 정함 — 본선 = [방향 바꾸기] · 다른 선에 닿은 끝 = [갈래 시점 지정]
* (그 점이 시점 · 꼬리에 닿은 머리 = 한 선으로 이음) · 자동 판정 없음.
* 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 · ✕ = 풂.
* 노드 — 경위도 · 지반고 · 측점 · 앞 선 길이 · 기울기 · [노드 지움] [지점으로](그 자리에 지점 · 이미 있으면 숨김).
* 선(토막) — 측점 · 길이 · 지반 기울기 · 든 연도 · [다시 긋기](하나 · 599) [선 지움](가운데 = 구역 둘).
* 구역 — 길이 · 노드 수 · 연도(구역 모든 구간) · 구간 줄 · [구역 지움].
* 전체 — 길이 · 구역 수 · 해마다 길이 · [전체 지움](되돌리기 됨).
* 연도 구간(왼쪽 해 · 구간 줄) — 연도(모두 같으면 그 해 · 다르면 「여러 해」) · 구간 줄 · 마커 — 앞뒤 해 · [마커 지움].
* 연도 고름 = 고른 구간 모두 그 해 = 되돌리기 한 걸음 · 보기만 계정 = 값만.
* 좌측 「구역 · 연도」 목록 = `UI_Pieces.ts`(페이지가 붙임).
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import { createButton, showToast } from "@ui/ui_template_elements";
import { createFloatPanel } from "@ui/ui_template_float_panel";
import { createSelectField } from "@ui/ui_template_input";
import { createSegment } from "@ui/ui_template_segment";
import { createKv, type KvRow } from "@ui/ui_template_kv";
import { stationLabel } from "../common_util/common_util_station";
import { lineNames, setPiecesYear, type PlanNetwork } from "./logic/D03_PlanAnalysis_Plan";
import type { PlanEdit, PlanScope } from "./D03_PlanAnalysis_UI_Edit";
import { groundSlopePct, lineLength, type HeightAt } from "./logic/D03_PlanAnalysis_Draw";
import {
lineNames,
samePoint,
setPiecesYear,
type PlanNetwork,
type PlanPiece,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import {
placeNode,
segmentPath,
segmentPieces,
segmentRange,
} from "./logic/D03_PlanAnalysis_Select";
import { nodeOff, nodeRecord } from "./logic/D03_PlanAnalysis_Span";
import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm";
import {
createDrawKindFields,
DRAW_DEFAULT_LENGTH_M,
DRAW_DEFAULT_SLOPE_PCT,
type DrawKindValue,
} from "./D03_PlanAnalysis_UI_DrawPanel";
import type { PlanEdit, PlanSelection } from "./D03_PlanAnalysis_UI_Edit";
import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
import { selectionLength } from "./D03_PlanAnalysis_UI_Select";
/** 조각 연도 고를 수 — 사업연도 시작부터. */
const YEAR_SPAN = 10;
const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1);
const meters = (m: number): string => `${m.toFixed(1)} m`;
const percent = (pct: number | null): string => (pct === null ? "-" : `${pct.toFixed(1)} %`);
export interface PlanPropsParams {
network: () => PlanNetwork;
@@ -32,6 +57,15 @@ export interface PlanPropsParams {
editable: () => boolean;
yearStart: () => number;
commit: (next: PlanNetwork) => void;
heightAt: () => HeightAt;
epsg: () => number;
/** 지점(노드 판 [지점으로] — 이미 있으면 숨김). */
points: () => Vertex[];
addPoint: (point: Vertex) => void;
/** 노드 판 방식 · 값 바꿈 — 노드 · 그 자리 지점 한 걸음. */
commitPoints: (points: Vertex[], next: PlanNetwork) => void;
/** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */
respan: (line: string, from: number, to: number) => boolean;
}
export interface PlanProps {
@@ -46,7 +80,7 @@ export interface PlanProps {
export function createPlanProps(params: PlanPropsParams): PlanProps {
const { edit } = params;
const float = createFloatPanel({
title: "고른 구간",
title: "고름",
draggable: true,
onClose: () => edit.select([]),
});
@@ -61,104 +95,237 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
return [...years].sort().map((year) => ({ value: String(year), text: `${year}년` }));
}
function renderPanel(): void {
const picked = edit.selected();
float.root.hidden = !picked;
if (!picked) return float.body.replaceChildren();
const network = params.network();
const names = lineNames(network.lines);
const pieces = network.pieces.filter((piece) => picked.pieces.includes(piece.id));
const lineIds = new Set(pieces.map((piece) => piece.line));
const total = pieces.reduce((sum, piece) => sum + piece.to_m - piece.from_m, 0);
const title = float.root.querySelector(".ui-float-panel__title");
if (title) title.textContent = `구간 ${pieces.length} · 선 ${lineIds.size} · ${meters(total)}`;
const editable = params.editable();
const scope = createSegment({
options: [
{ value: "piece", label: "구간" },
{ value: "line", label: "선" },
{ value: "year", label: "같은 해" },
],
value: picked.scope,
size: 24,
ariaLabel: "고름 넓이",
onChange: (value) => edit.setScope(value as PlanScope),
});
scope.root.dataset.role = "props-scope";
const button = (label: string, role: string, onClick: () => void): HTMLButtonElement => {
const b = createButton({ label, variant: "ghost", size: 24, onClick });
b.dataset.role = role;
return b;
};
/** 연도 고름 — 구간 모두 그 해(되돌리기 한 걸음). */
function yearField(pieces: PlanPiece[]): HTMLElement {
const years = new Set(pieces.map((piece) => piece.year));
const options = yearOptions();
const ids = pieces.map((piece) => piece.id);
const year = createSelectField({
label: "연도",
options: years.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options,
value: years.size > 1 ? "" : String(pieces[0]?.year ?? ""),
size: 24,
ariaLabel: "고른 구간 연도",
disabled: !editable,
disabled: !params.editable(),
onChange: (value) =>
value && params.commit(setPiecesYear(params.network(), picked.pieces, Number(value))),
value && params.commit(setPiecesYear(params.network(), ids, Number(value))),
});
year.select.dataset.role = "props-year";
return year.root;
}
/** 구간 줄 — 누름 = 그 구간만. */
function pieceRows(network: PlanNetwork, pieces: PlanPiece[]): HTMLElement {
const names = lineNames(network.lines);
const rows = document.createElement("div");
rows.className = "d03-plan__props-rows";
for (const piece of pieces) {
const index = network.lines.findIndex((line) => line.id === piece.line);
const row = createButton({
label:
`${names[index]} ${station(piece.from_m)}~${station(piece.to_m)} · ` +
const row = button(
`${names[index]} ${station(piece.from_m)}~${station(piece.to_m)} · ` +
`${meters(piece.to_m - piece.from_m)} · ${piece.year}년`,
variant: "ghost",
size: 24,
onClick: () => edit.select([piece.id]),
});
"props-piece",
() => edit.select([piece.id]),
);
row.dataset.piece = piece.id;
rows.append(row);
}
return rows;
}
const actions = document.createElement("div");
actions.className = "d03-plan__row";
const index = edit.lineIndex();
if (editable && index >= 0) {
const flip = createButton({
label: "방향 바꾸기",
variant: "ghost",
size: 24,
onClick: edit.flipLine,
});
flip.title = "측점 No.0 쪽(시점 쪽)을 반대 끝으로 바꿉니다.";
flip.disabled = !!network.lines[index].branch;
flip.dataset.role = "props-flip";
const drop = createButton({
label: "선 지움",
variant: "ghost",
size: 24,
onClick: edit.dropLine,
});
drop.dataset.role = "props-drop";
const branch = createButton({
label: "갈래 시점 지정",
variant: "ghost",
size: 24,
onClick: edit.branchLine,
});
branch.title = "다른 선에 닿은 끝을 시점(No.0)으로 · 그 선의 갈래로 붙입니다.";
branch.hidden = !!network.lines[index].branch;
branch.dataset.role = "props-branch";
actions.append(flip, branch, drop);
/** 노드 방식 · 값(598-4 · 599-1 부품) — 방식 고름 = 바로 · 수 칸 = Enter · 칸 떠날 때 · 앞 노드 기준 다시 놓음. */
function nodeKind(line: number, vertex: number): HTMLElement {
const record = params.network().lines[line].nodes?.[vertex] ?? null;
let value: DrawKindValue = {
kind: record?.kind ?? "free",
lengthM: record?.length_m ?? DRAW_DEFAULT_LENGTH_M,
slopePct: record?.slope_pct ?? DRAW_DEFAULT_SLOPE_PCT,
};
const apply = (): void => {
const network = params.network();
const next = nodeRecord(value.kind, value.lengthM, value.slopePct);
const out = placeNode(network, params.points(), line, vertex, next, params.heightAt());
if (typeof out === "string") return void showToast(out, "info");
if (JSON.stringify(out.network.lines) !== JSON.stringify(network.lines))
params.commitPoints(out.points, out.network);
};
const fields = createDrawKindFields({
value,
onChange: (next) => {
const kindChanged = next.kind !== value.kind;
value = next;
if (kindChanged) apply();
},
});
fields.root.addEventListener("change", apply);
fields.root.dataset.role = "props-node-kind";
fields.setDisabled(!params.editable());
return fields.root;
}
/** 고름 꼴마다 — 머리 글 · 몸 · 단추. */
function body(
picked: PlanSelection,
network: PlanNetwork,
): { title: string; parts: HTMLElement[]; actions: HTMLButtonElement[] } {
const names = lineNames(network.lines);
const nameOf = (id: string | null): string =>
names[network.lines.findIndex((line) => line.id === id)] ?? "";
const z = params.heightAt();
const pieces = network.pieces.filter((piece) => picked.pieces.includes(piece.id));
const length = selectionLength(edit.paths());
if (picked.kind === "node") {
const { line, vertex } = picked.node!;
const vertices = network.lines.find((l) => l.id === line)!.vertices;
const point = vertices[vertex];
const tm = tmParams(params.epsg());
const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null;
const height = z(point[0], point[1]);
const rows: KvRow[] = [
{
label: "경위도",
value: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : null,
},
{ label: "지반고", value: Number.isFinite(height) ? `${height.toFixed(2)} m` : null },
{ label: "측점", value: station(lineLength(vertices.slice(0, vertex + 1))) },
];
if (vertex > 0) {
const [a, b] = [vertices[vertex - 1], vertices[vertex]];
rows.push(
{ label: "앞 선 길이", value: meters(lineLength([a, b])) },
{ label: "앞 선 기울기", value: percent(groundSlopePct(a, b, z)) },
);
}
const entry = network.lines.find((l) => l.id === line)!;
if (nodeOff(entry, vertex, z)) rows.push({ label: "기록", value: "⚠ 지금 자리와 어긋남" });
const parts: HTMLElement[] = [createKv(rows)];
if (vertex > 0) parts.push(nodeKind(network.lines.indexOf(entry), vertex));
const toPoint = button("지점으로", "props-to-point", () =>
params.addPoint([point[0], point[1]]),
);
toPoint.hidden = params.points().some((p) => samePoint(p, point));
return {
title: `${nameOf(line)} · 노드 ${vertex + 1}`,
parts,
actions: [
button("노드 지움", "props-drop-node", () => edit.dropNode(picked.node!)),
toPoint,
],
};
}
if (editable && picked.mark) {
const dropMark = createButton({
label: "마크 지움",
variant: "ghost",
size: 24,
onClick: edit.dropMark,
});
dropMark.dataset.role = "props-drop-mark";
actions.append(dropMark);
if (picked.kind === "segment") {
const single = picked.segments.length === 1 ? picked.segments[0] : null;
const years = [
...new Set(
segmentPieces(network, picked.segments).map(
(id) => network.pieces.find((p) => p.id === id)?.year,
),
),
].sort();
const rows: KvRow[] = [{ label: "길이", value: meters(length) }];
if (single) {
const [from, to] = segmentRange(network, single)!;
const [a, b] = segmentPath(network, single)!;
rows.unshift({ label: "측점", value: `${station(from)} ~ ${station(to)}` });
rows.push({ label: "지반 기울기", value: percent(groundSlopePct(a, b, z)) });
}
rows.push({ label: "연도", value: years.map((y) => `${y}년`).join(" · ") });
const actions = [button("선 지움", "props-drop-segment", edit.dropSelected)];
if (single)
actions.unshift(
button("다시 긋기", "props-respan", () =>
params.respan(single.line, single.index, single.index + 1),
),
);
return {
title: single
? `${nameOf(single.line)} · 선 ${single.index + 1}`
: `선 ${picked.segments.length}개`,
parts: [createKv(rows)],
actions,
};
}
float.body.replaceChildren(scope.root, year.root, rows, actions);
if (picked.kind === "line") {
const vertices = network.lines.find((l) => l.id === picked.line)?.vertices ?? [];
return {
title: nameOf(picked.line),
parts: [
createKv([
{ label: "길이", value: meters(length) },
{ label: "노드", value: `${vertices.length}개` },
]),
yearField(pieces),
pieceRows(network, pieces),
],
actions: [button("구역 지움", "props-drop-line", () => edit.dropRegion(picked.line!))],
};
}
if (picked.kind === "all") {
const byYear = new Map<number, number>();
for (const p of network.pieces)
byYear.set(p.year, (byYear.get(p.year) ?? 0) + p.to_m - p.from_m);
return {
title: "전체",
parts: [
createKv([
{ label: "길이", value: meters(length) },
{ label: "구역", value: `${network.lines.length}개` },
...[...byYear]
.sort(([a], [b]) => a - b)
.map(([year, m]) => ({ label: `${year}년`, value: meters(m) })),
]),
],
actions: [button("전체 지움", "props-drop-all", edit.dropAll)],
};
}
// 연도 구간 · 마커
const lineIds = new Set(pieces.map((piece) => piece.line));
const total = pieces.reduce((sum, piece) => sum + piece.to_m - piece.from_m, 0);
const parts: HTMLElement[] = [yearField(pieces), pieceRows(network, pieces)];
const actions: HTMLButtonElement[] = [];
const mark = network.pieces.find((p) => p.id === picked.mark);
if (mark) {
const before = network.pieces.find(
(p) => p.line === mark.line && Math.abs(p.to_m - mark.from_m) < 1e-3,
);
parts.unshift(
createKv([
{ label: "측점", value: station(mark.from_m) },
{ label: "앞 · 뒤 해", value: `${before?.year ?? "-"}년 · ${mark.year}년` },
]),
);
actions.push(button("마커 지움", "props-drop-mark", edit.dropMark));
}
return {
title: `구간 ${pieces.length} · 구역 ${lineIds.size} · ${meters(total)}`,
parts,
actions,
};
}
function renderPanel(): void {
const picked = edit.selected();
float.root.hidden = !picked;
if (!picked) return float.body.replaceChildren();
const { title, parts, actions } = body(picked, params.network());
const head = float.root.querySelector(".ui-float-panel__title");
if (head) head.textContent = title;
float.root.dataset.kind = picked.kind;
const row = document.createElement("div");
row.className = "d03-plan__row";
if (params.editable()) row.append(...actions);
float.body.replaceChildren(...parts, row);
}
return {
@@ -0,0 +1,236 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Select.ts
* D03 고름 상태(PLAN 598-1 · 요구 463 9 · 13) — 노드 → 선(토막) → 구역 → 전체 · 연도 구간(왼쪽 해 줄) · 마커.
* 넓힘 = 같은 것을 다시 누름 — 씨(처음 누른 노드 · 토막)에서 한 단 위 · 전체 다음 = 씨로 돌아옴.
* 「같은 해」 = 왼쪽 목록 해 줄로만(`year`).
* 상태 = 화면 기억(저장 안 함) · 꼴이 바뀌면 `sync` 가 사라진 것을 뺌.
* ========================================================================== */
import { lineLength, sliceLine } from "./logic/D03_PlanAnalysis_Draw";
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
import {
lineSegments,
nodeSegment,
sameSegment,
segmentPath,
segmentPieces,
type PlanNode,
type PlanSegment,
} from "./logic/D03_PlanAnalysis_Select";
export type PlanSelectKind = "node" | "segment" | "line" | "all" | "pieces" | "mark";
/** 왼쪽 목록 밝힘 꼴(구간 · 선 · 같은 해). */
export type PlanScope = "piece" | "line" | "year";
export interface PlanSelection {
kind: PlanSelectKind;
/** 노드 고름 · 노드 씨. */
node: PlanNode | null;
/** 토막 고름(Shift · 끌어 고르기 = 여럿). */
segments: PlanSegment[];
/** 구역 고름 · 씨가 든 구역. */
line: string | null;
/** 고름에 든 연도 구간 id(구간 · 마커 고름 = 그대로 · 그 밖 = 걸친 구간). */
pieces: string[];
/** 고른 마커(그 마커가 여는 구간 id). */
mark: string | null;
scope: PlanScope;
/** 넓힘 씨 — 처음 누른 노드 · 토막. */
seed: { node: PlanNode } | { segment: PlanSegment } | null;
}
const NEXT: Partial<Record<PlanSelectKind, PlanSelectKind>> = {
node: "segment",
segment: "line",
line: "all",
};
export interface PlanSelect {
get: () => PlanSelection | null;
clear: () => void;
node: (node: PlanNode) => void;
/** 토막 — `toggle` = 지금 토막 고름에 더함 · 뺌. */
segments: (segments: PlanSegment[], toggle?: boolean) => void;
region: (line: string) => void;
all: () => void;
/** 연도 구간 — 마커 고름 = 그 마커가 여는 구간. */
pieces: (ids: string[], mark?: string | null, scope?: PlanScope) => void;
/** 넓힘 한 단(씨 기준). */
grow: () => void;
/** 이 노드 · 토막이 지금 고름 안인가(다시 누름 = 넓힘). */
holds: (hit: { node?: PlanNode; segment?: PlanSegment }) => boolean;
/** 밝힐 꺾은선. */
paths: () => Vertex[][];
sync: () => void;
}
export function createPlanSelect(network: () => PlanNetwork, onChange: () => void): PlanSelect {
let selected: PlanSelection | null = null;
function piecesOf(next: Omit<PlanSelection, "pieces" | "scope">): string[] {
const net = network();
if (next.kind === "segment") return segmentPieces(net, next.segments);
if (next.kind === "line")
return net.pieces.filter((p) => p.line === next.line).map((p) => p.id);
if (next.kind === "all") return net.pieces.map((p) => p.id);
return [];
}
function choose(
next: Omit<PlanSelection, "pieces" | "scope"> & Partial<PlanSelection>,
quiet = false,
): void {
selected = {
...next,
pieces: next.pieces ?? piecesOf(next),
scope: next.scope ?? (next.kind === "line" ? "line" : "piece"),
};
if (!quiet) onChange();
}
const empty = { node: null, segments: [], line: null, mark: null, seed: null };
function node(at: PlanNode): void {
choose({ ...empty, kind: "node", node: at, line: at.line, seed: { node: at } });
}
function segments(list: PlanSegment[], toggle = false): void {
let next = list;
if (toggle && selected?.kind === "segment") {
const has = (s: PlanSegment): boolean => selected!.segments.some((t) => sameSegment(s, t));
next = [
...selected.segments.filter((s) => !list.some((t) => sameSegment(s, t))),
...list.filter((s) => !has(s)),
];
}
if (!next.length) return clear();
choose({
...empty,
kind: "segment",
segments: next,
line: next[0].line,
seed: { segment: next[0] },
});
}
function clear(): void {
selected = null;
onChange();
}
/** 씨 기준 한 단 — 노드 → 토막 → 구역 → 전체 → 씨. */
function grow(): void {
if (!selected?.seed) return;
const seed = selected.seed;
const next = NEXT[selected.kind];
const line = "node" in seed ? seed.node.line : seed.segment.line;
if (next === "segment") {
const seg = "node" in seed ? nodeSegment(network(), seed.node) : seed.segment;
if (seg) choose({ ...empty, kind: "segment", segments: [seg], line, seed });
} else if (next === "line") choose({ ...empty, kind: "line", line, seed });
else if (next === "all") choose({ ...empty, kind: "all", line, seed });
else if ("node" in seed) node(seed.node);
else choose({ ...empty, kind: "segment", segments: [seed.segment], line, seed });
}
function holds(hit: { node?: PlanNode; segment?: PlanSegment }): boolean {
const s = selected;
if (!s || s.kind === "pieces" || s.kind === "mark") return false;
if (s.kind === "all") return true;
const line = hit.node?.line ?? hit.segment?.line;
if (s.kind === "line") return line === s.line;
if (s.kind === "node")
return !!hit.node && hit.node.line === s.node!.line && hit.node.vertex === s.node!.vertex;
if (hit.segment) return s.segments.some((t) => sameSegment(t, hit.segment!));
return s.segments.some(
(t) =>
t.line === hit.node!.line &&
(t.index === hit.node!.vertex || t.index + 1 === hit.node!.vertex),
);
}
function paths(): Vertex[][] {
const s = selected;
const net = network();
if (!s || s.kind === "node") return [];
if (s.kind === "segment")
return s.segments
.map((seg) => segmentPath(net, seg))
.filter((p): p is [Vertex, Vertex] => !!p);
if (s.kind === "line") return net.lines.filter((l) => l.id === s.line).map((l) => l.vertices);
if (s.kind === "all") return net.lines.map((l) => l.vertices);
return s.pieces.flatMap((id) => {
const piece = net.pieces.find((p) => p.id === id);
const v = net.lines.find((l) => l.id === piece?.line)?.vertices;
return piece && v ? [sliceLine(v, piece.from_m, piece.to_m)] : [];
});
}
/** 사라진 것 뺌 — 부르는 쪽(페이지 `refresh`)이 다시 그림(여기서 onChange 를 부르면 돌고 돎). */
function sync(): void {
const s = selected;
if (!s) return;
const net = network();
const vertexCount = (line: string | null): number =>
net.lines.find((l) => l.id === line)?.vertices.length ?? 0;
const okNode = (n: PlanNode): boolean => n.vertex < vertexCount(n.line);
const okSeg = (g: PlanSegment): boolean => g.index + 1 < vertexCount(g.line);
if (s.kind === "pieces" || s.kind === "mark") {
const ids = new Set(net.pieces.map((p) => p.id));
const pieces = s.pieces.filter((id) => ids.has(id));
selected = pieces.length
? { ...s, pieces, mark: s.mark && ids.has(s.mark) ? s.mark : null }
: null;
if (selected?.kind === "mark" && !selected.mark) selected = { ...selected, kind: "pieces" };
return;
}
const seed =
s.seed && ("node" in s.seed ? okNode(s.seed.node) : okSeg(s.seed.segment)) ? s.seed : null;
const segments = s.segments.filter(okSeg);
const alive = {
all: net.lines.length > 0,
line: vertexCount(s.line) > 0,
node: !!s.node && okNode(s.node),
segment: segments.length > 0,
}[s.kind];
if (alive) choose({ ...s, segments, seed, pieces: undefined }, true);
else selected = null;
}
return {
get: () => selected,
clear,
node,
segments,
region: (line) => choose({ ...empty, kind: "line", line, seed: lineSeed(line) }),
all: () => choose({ ...empty, kind: "all", seed: null }),
pieces(ids, mark = null, scope = "piece") {
const list = [...new Set(ids)];
if (!list.length) return clear();
const net = network();
choose({
...empty,
kind: mark ? "mark" : "pieces",
line: net.pieces.find((p) => p.id === list[0])?.line ?? null,
pieces: list,
mark,
scope,
});
},
grow,
holds,
paths,
sync,
};
/** 구역 씨 = 첫 토막(구역에서 넓힘 = 전체 → 첫 토막). */
function lineSeed(line: string): { segment: PlanSegment } | null {
const first = lineSegments(network(), line)[0];
return first ? { segment: first } : null;
}
}
/** 고름 길이(m) — 토막 · 구역 · 전체 · 구간. */
export function selectionLength(paths: Vertex[][]): number {
return paths.reduce((sum, path) => sum + lineLength(path), 0);
}
@@ -0,0 +1,126 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Site.ts
* D03 「위치」 칸(PLAN 596-2 · 요구 463-3~6 · 19) — 지금 기준점 주소 한 줄 · 주소 · 장소 찾기
* (서버 VWorld `searchPlaces` · 부품 `createFindList` · Enter = 찾기 · ↑ ↓ Enter = 고름) ·
* 알림 한 줄(받기 실패 · 끊김 · 기준점 없음 · 못 불러옴만) + [다시 받기] · [임도망 올리기].
* 고름 = 페이지가 받기 물음(멂 · 다른 TM 띠) → 기준점 캐시 → 지도 이동 → 둘레 받기 · [저장] 때 정본(등록과 한 칸).
* ========================================================================== */
import { pickFiles } from "@ui/ui_template_drop";
import { createButton } from "@ui/ui_template_elements";
import { createFindList, type FindList, type FindRow } from "@ui/ui_template_find_list";
import { t } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { searchPlaces, type PlaceHit } from "./D03_PlanAnalysis_Api";
/** 찾기 글 최소 길이(서버 VWorld 한 글자 = 쓸모없는 후보). */
const MIN_QUERY = 2;
export interface PlanSiteParams {
/** 후보 고름 — 받기 · 캐시 · 이동은 페이지. */
onPick: (hit: PlaceHit) => void;
onRetry: () => void;
onUpload: (files: File[]) => void;
}
export interface PlanSiteState {
address: string | null;
/** 알림(실패 · 끊김 · 기준점 없음 · 오류) — 빈 값 = 숨김. */
note: string;
retry: boolean;
editable: boolean;
}
export interface PlanSite {
/** 칸 몸(카드에 바로 붙임 · 카드 간격을 그대로 씀). */
parts: HTMLElement[];
render: (state: PlanSiteState) => void;
}
/** 주소 · 장소 찾기 칸(부품 `createFindList`) — Enter = 찾기 · ↑ ↓ Enter · 누름 = 고름 ·
* 등록(D01) 지도 머리 · 이 「위치」 칸이 같이 씀. */
export function createPlaceFind(onPick: (hit: PlaceHit) => void): FindList {
const find = createFindList({ placeholder: t("DE_Reg_Find") });
const list = find.root.querySelector<HTMLElement>(".ui-find__list")!;
let asked = 0; // 옛 찾기 답 버림
// 빈 알림 · 빈 목록은 숨김(칸 사이 빈 틈 없앰)
const say = (text: string): void => {
find.hint.textContent = text;
find.hint.hidden = !text;
};
const show = (rows: FindRow[]): void => {
find.setRows(rows);
list.hidden = !rows.length;
};
say("");
show([]);
async function run(): Promise<void> {
const query = find.search.value.trim();
show([]);
if (query.length < MIN_QUERY) return say(t("DE_Reg_FindShort"));
const at = ++asked;
say(t("DE_Reg_FindBusy"));
try {
const hits = await searchPlaces(query);
if (at !== asked) return;
say(hits.length ? "" : t("DE_Reg_FindNone"));
show(
hits.map((hit, i) => ({
key: String(i),
tag: t(hit.kind === "place" ? "DE_Reg_FindPlace" : "DE_Reg_FindAddress"),
name: hit.title,
sub: hit.address !== hit.title ? hit.address : undefined,
onPick: () => {
show([]);
find.search.value = hit.title;
onPick(hit);
},
})),
);
} catch (error) {
if (at === asked)
say(`${t("DE_Reg_FindFail")} — ${error instanceof Error ? error.message : error}`);
}
}
// 고를 줄이 있으면 부품이 Enter 를 먼저 씀(고름) · 없으면 찾기 · 글을 고치면 옛 줄 걷음
find.search.addEventListener("input", () => show([]));
find.search.addEventListener("keydown", (event) => {
if (event.key !== "Enter" || event.defaultPrevented || event.isComposing) return;
event.preventDefault();
void run();
});
return find;
}
export function createPlanSite(params: PlanSiteParams): PlanSite {
const address = createText("", "muted", 14);
const note = createText("", "error");
const find = createPlaceFind(params.onPick);
const retry = createButton({
label: "다시 받기",
variant: "filled",
size: 28,
onClick: params.onRetry,
});
const upload = createButton({
label: "임도망 올리기",
variant: "ghost",
size: 28,
onClick: () =>
pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, params.onUpload),
});
upload.title = "임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)";
return {
parts: [address, find.root, note, retry, upload],
render(state) {
address.textContent = state.address ?? "";
address.hidden = !state.address;
note.textContent = state.note;
note.hidden = !state.note;
find.root.hidden = upload.hidden = !state.editable;
retry.hidden = !state.editable || !state.retry;
},
};
}
@@ -0,0 +1,236 @@
/* =============================================================================
* D03_PlanAnalysis_UI_SlopeBands.ts
* D03 급경사 범위 옵션(PLAN 603 · 요구 466 · 467 · 468) — 범례 급경사 줄 ⚙ → 모달
* (범위 줄 = 아래 % · 위 % · 색 · 지움 · 칸 옆 ° 환산 · [범위 추가] · [기본값] · [적용] · 빈 값 · 겹침 막음).
* 기억 = 화면 취향(`aislo:pref:d03.slope-bands` · 계정에 붙음 · 프로젝트 무관).
* 칠하기 = 서버 값 그림(회색 · 칸 = 기울기 % 내림) → 범위 색 그림(서버 왕복 없음 · CLAUDE.md 5장).
* 범위 · 비율 셈 = `logic/D03_PlanAnalysis_SlopeBands.ts` · 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`.
* ========================================================================== */
import { createButton, createIconButton } from "@ui/ui_template_button";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { createLegend } from "@ui/ui_template_flow_legend";
import { openModal } from "@ui/ui_template_modal";
import { themeColor } from "@ui/ui_template_palette";
import { createText } from "@ui/ui_template_text";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { loadImage } from "./D03_PlanAnalysis_UI_LayerDefs";
import {
DEFAULT_SLOPE_BANDS,
SLOPE_COLORS,
SLOPE_MAX_BANDS,
SLOPE_MAX_PCT,
bandOf,
checkBands,
parseBands,
pctToDeg,
type SlopeBand,
} from "./logic/D03_PlanAnalysis_SlopeBands";
const PREF_KEY = "aislo:pref:d03.slope-bands";
/** 칠한 칸 투명도(겹 투명도 0.55 는 따로). */
const ALPHA = 150;
export const readSlopeBands = (): SlopeBand[] => parseBands(readByKey(PREF_KEY));
export const writeSlopeBands = (bands: SlopeBand[]): void =>
writeByKey(PREF_KEY, JSON.stringify(bands));
/** 값 그림 → 칸 값(빨강 자리 = 회색 값). */
export function slopeValues(image: HTMLImageElement): ImageData {
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext("2d", { willReadFrequently: true })!;
context.drawImage(image, 0, 0);
return context.getImageData(0, 0, canvas.width, canvas.height);
}
/** 범위대로 칠한 그림(주소 = blob · 다 쓰면 부르는 쪽이 `URL.revokeObjectURL`). */
export async function paintSlope(
values: ImageData,
bands: SlopeBand[],
): Promise<{ url: string; image: HTMLImageElement }> {
const rgb = bands.map((band) => hexRgb(themeColor(band.color, "#facc15")));
const lut = new Uint32Array(256);
const view = new DataView(lut.buffer);
for (let v = 0; v < 256; v++) {
const i = bandOf(v, bands);
if (i >= 0) view.setUint32(v * 4, ((rgb[i] << 8) | ALPHA) >>> 0, false);
}
const out = new ImageData(values.width, values.height);
const src = values.data;
const dst = new Uint32Array(out.data.buffer);
for (let p = 0; p < dst.length; p++) dst[p] = lut[src[p * 4]];
const canvas = document.createElement("canvas");
canvas.width = values.width;
canvas.height = values.height;
canvas.getContext("2d")!.putImageData(out, 0, 0);
const blob = await new Promise<Blob>((done, fail) =>
canvas.toBlob(
(got) => (got ? done(got) : fail(new Error("그림을 만들지 못했습니다"))),
"image/png",
),
);
const url = URL.createObjectURL(blob);
return { url, image: await loadImage(url) };
}
/** `#rrggbb` → 0xrrggbb(못 읽으면 노랑). */
function hexRgb(color: string): number {
const hex = /^#?([0-9a-f]{6})$/i.exec(color.trim())?.[1];
return hex ? parseInt(hex, 16) : 0xfacc15;
}
/** 범례 급경사 줄 ⚙. */
export const slopeGear = (onClick: () => void): HTMLButtonElement => {
const button = createIconButton({
icon: "settings",
title: "급경사 범위 바꾸기(기울기 %)",
size: 20,
onClick,
});
button.dataset.role = "slope-bands";
return button;
};
const degText = (value: string): string => {
const pct = Number(value);
return value.trim() && Number.isFinite(pct) ? `≈ ${pctToDeg(pct).toFixed(1)}°` : "—";
};
/** 옵션 모달 — [적용] = 막을 것 없으면 `onApply` 후 닫음. */
export function openSlopeBands(current: SlopeBand[], onApply: (bands: SlopeBand[]) => void): void {
let rows: Array<{ root: HTMLElement; read: () => SlopeBand }> = [];
const list = document.createElement("div");
const error = createText("", "error");
error.hidden = true;
const addButton = createButton({
label: "범위 추가",
variant: "ghost",
size: 28,
iconStart: "plus",
onClick: () => {
const last = rows.at(-1)?.read();
const low = last && Number.isFinite(last.high) ? Math.min(last.high, SLOPE_MAX_PCT - 1) : 0;
const color = SLOPE_COLORS[rows.length % SLOPE_COLORS.length].color;
add({ low, high: SLOPE_MAX_PCT, color });
},
});
addButton.dataset.role = "slope-band-add";
function refresh(): void {
addButton.disabled = rows.length >= SLOPE_MAX_BANDS;
error.hidden = true;
}
function add(band: SlopeBand): void {
const field = (label: string, value: number) => {
const got = createInputField({
label,
type: "number",
unit: "%",
min: 0,
max: SLOPE_MAX_PCT,
size: 28,
value: Number.isFinite(value) ? String(value) : "",
});
const deg = createText(degText(got.input.value), "muted");
got.input.addEventListener("input", () => {
deg.textContent = degText(got.input.value);
refresh();
});
got.root.append(deg);
return got;
};
const low = field("아래", band.low);
const high = field("위", band.high);
const color = createSelectField({
label: "색",
size: 28,
options: SLOPE_COLORS.map((c) => ({ value: c.color, text: c.name })),
value: band.color,
});
const chip = createLegend([{ label: "", color: `var(${band.color})` }]);
color.select.addEventListener("change", () =>
chip
.querySelector<HTMLElement>(".ui-legend__mark")!
.style.setProperty("--legend-color", `var(${color.select.value})`),
);
const root = document.createElement("div");
root.className = "d03-plan__row";
root.dataset.role = "slope-band";
const entry = {
root,
read: (): SlopeBand => ({
low: low.input.value.trim() === "" ? NaN : Number(low.input.value),
high: high.input.value.trim() === "" ? NaN : Number(high.input.value),
color: color.select.value,
}),
};
const remove = createIconButton({
icon: "trash",
title: "이 범위 지움",
size: 28,
onClick: () => {
rows = rows.filter((row) => row !== entry);
root.remove();
refresh();
},
});
root.append(low.root, high.root, color.root, chip, remove);
rows.push(entry);
list.append(root);
refresh();
}
function fill(bands: SlopeBand[]): void {
rows = [];
list.replaceChildren();
bands.forEach(add);
}
openModal({
title: "급경사 범위(기울기 %)",
closeLabel: "닫기",
width: "40rem",
mount: (body, close, actions) => {
fill(current);
const reset = createButton({
label: "기본값",
variant: "ghost",
size: 28,
onClick: () => fill(DEFAULT_SLOPE_BANDS),
});
reset.dataset.role = "slope-band-default";
const apply = createButton({
label: "적용",
size: 28,
onClick: () => {
const bands = rows.map((row) => row.read());
const why = checkBands(bands);
if (why) {
error.lastChild!.textContent = why;
error.hidden = false;
return;
}
onApply(bands.sort((a, b) => a.low - b.low));
close();
},
});
apply.dataset.role = "slope-band-apply";
actions.append(apply);
const foot = document.createElement("div");
foot.className = "d03-plan__row";
foot.append(addButton, reset);
body.append(
createText(
`0~${SLOPE_MAX_PCT} % · 아래 포함 · 위 뺌 · 겹치면 안 됨 · 기본 = 15 · 35 · 40°(지식DB)`,
"hint",
),
list,
foot,
error,
);
},
});
}
+33 -32
View File
@@ -106,28 +106,6 @@
gap: 4px;
}
.d03-plan__piece-head {
margin: 6px 0 0;
font-weight: 600;
color: var(--color-text);
}
.d03-plan__piece {
display: flex;
gap: var(--spacing-8);
align-items: center;
justify-content: space-between;
}
.d03-plan__piece > .ui-text {
margin: 0;
}
.d03-plan__piece > .ui-field {
flex: 0 0 6.5rem;
margin: 0;
}
/* 지형 받기 범위 미리보기 — 카카오 지도 */
.d03-plan__extent > .ui-text {
margin: 0 0 6px;
@@ -152,12 +130,6 @@
margin-bottom: 6px;
}
/* 580-3 고른 선 · 조각 줄 밝힘 */
.d03-plan__piece.is-selected {
background: var(--color-accent-soft);
border-radius: var(--radius-md);
}
/* 585-2 고른 구간 속성 판 — 도구줄 아래 오른쪽 · 몸 스크롤 · 구간 줄 = 고스트 단추 */
.d03-plan__props {
top: calc(var(--spacing-8) * 6);
@@ -188,12 +160,22 @@
justify-content: flex-start;
}
.d03-plan__piece[role="button"] {
cursor: pointer;
/* 597 지점 칸 — [추가] 머리 · 펼친 줄(이름 · 경도 · 위도) */
.d03-plan__points-head {
display: flex;
justify-content: flex-end;
margin-bottom: var(--spacing-8);
}
.d03-plan__piece-year {
font-weight: 600;
.d03-plan__point-edit {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-8) var(--spacing-8) calc(var(--spacing-8) * 2);
}
.d03-plan__point-edit > .ui-field {
margin: 0;
}
/* 599 그리기 칸 — 방식 칸(마디 고름 2 × 2 · 길이 · 기울기 두 열) */
@@ -213,3 +195,22 @@
.d03-plan__draw-fields .ui-field {
margin: 0;
}
/* 597 구역 · 연도 목록 — 구역 줄(이름 칸 · 연도 고름 · 지움) · 구간 줄(측점 글 작게) */
.d03-plan__line-tail {
display: flex;
gap: var(--spacing-4);
align-items: center;
}
.d03-plan__pieces .ui-list-row__label > .ui-field {
margin: 0;
}
.d03-plan__piece-row {
padding-left: calc(var(--spacing-8) * 2);
}
.d03-plan__piece-row .ui-list-row__label {
font-size: var(--text-caption);
}
@@ -0,0 +1,97 @@
/* =============================================================================
* D03_PlanAnalysis_Measure.ts
* D 예정노선 거리재기 「노선 따라」(PLAN 600-2 · 요구 463-23) — 첫 누름이 노선 위면 둘째 누름까지
* 노선(망)을 따라간 길 · 같은 선 = 누가 차 · 갈래 ↔ 본선 = 갈래 고리(`branch`)를 따라 올라가 만나는 점까지.
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
* 서로 이어지지 않은 본선끼리 = 길 없음(null) → 부르는 쪽이 곧은 거리로.
* ========================================================================== */
import {
distanceAlong,
lineLength,
pointAtDistance,
segmentLength,
sliceLine,
} from "./D03_PlanAnalysis_Draw";
import type { PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan";
/** 노선 위 한 자리 — 선 id · 그 선 위 누가(m). */
export interface RoutePos {
line: string;
atM: number;
}
export interface RouteSpan {
lengthM: number;
/** 두 자리 사이 노선 따라 꺾은선(앞 자리 → 뒤 자리). */
path: Vertex[];
/** 같은 선 위일 때만 — 두 누가(m). */
stations: [number, number] | null;
}
/** 자리에서 시작해 갈래 고리를 따라 올라간 (선 · 그 선에 들어선 누가) 목록 — 첫 항목 = 자리 자신. */
function upChain(network: PlanNetwork, pos: RoutePos): RoutePos[] {
const chain: RoutePos[] = [pos];
const seen = new Set([pos.line]);
let line = network.lines.find((entry) => entry.id === pos.line);
while (line?.branch) {
const parent = network.lines.find((entry) => entry.id === line!.branch!.line);
if (!parent || seen.has(parent.id)) break;
seen.add(parent.id);
chain.push({
line: parent.id,
atM: lineLength(parent.vertices.slice(0, line.branch.vertex + 1)),
});
line = parent;
}
return chain;
}
/** 두 자리 사이 노선 따라 길 — 이어지지 않으면 null. */
export function routeSpan(network: PlanNetwork, a: RoutePos, b: RoutePos): RouteSpan | null {
const up = upChain(network, a);
const down = upChain(network, b);
const ia = up.findIndex((step) => down.some((other) => other.line === step.line));
if (ia < 0) return null;
const ib = down.findIndex((step) => step.line === up[ia].line);
const legs: Array<[string, number, number]> = [];
// a → 갈래 첫 점(0 m) → 붙은 선 … 만나는 선
for (let k = 0; k < ia; k += 1) legs.push([up[k].line, up[k].atM, 0]);
legs.push([up[ia].line, up[ia].atM, down[ib].atM]);
// 만나는 선 → … → b
for (let k = ib - 1; k >= 0; k -= 1) legs.push([down[k].line, 0, down[k].atM]);
const path: Vertex[] = [];
let lengthM = 0;
for (const [id, from, to] of legs) {
const vertices = network.lines.find((entry) => entry.id === id)?.vertices ?? [];
if (vertices.length < 2) continue;
lengthM += Math.abs(to - from);
const piece = sliceLine(vertices, from, to);
if (from > to) piece.reverse();
for (const point of piece) {
const last = path[path.length - 1];
if (!last || segmentLength(last, point) > 1e-9) path.push(point);
}
}
const same = ia === 0 && ib === 0;
return { lengthM, path, stations: same ? [a.atM, b.atM] : null };
}
/** 점에서 가장 가까운 노선 위 자리 — `from` 과 이어진 선 안에서만. */
export function nearestOnRoute(
network: PlanNetwork,
point: Vertex,
from: RoutePos,
): (RoutePos & { point: Vertex }) | null {
let best: (RoutePos & { point: Vertex }) | null = null;
let bestGap = Infinity;
for (const line of network.lines) {
if (line.vertices.length < 2) continue;
const atM = distanceAlong(line.vertices, point);
const gap = segmentLength(pointAtDistance(line.vertices, atM), point);
if (gap >= bestGap || !routeSpan(network, from, { line: line.id, atM })) continue;
best = { line: line.id, atM, point: pointAtDistance(line.vertices, atM) };
bestGap = gap;
}
return best;
}
@@ -0,0 +1,77 @@
/* =============================================================================
* D03_PlanAnalysis_PointEdit.ts
* D03 지점 칸 손 고침 계산(PLAN 597-2 · 요구 463 7 · 8) — 화면 · fetch 없음(logic/).
* 이름 규칙 = `Plan.ts` 의 `nameError`(20 자 · 파일 이름 못 쓰는 글자 · 서버 400 짝) · 빈 값 = 「지점 N」 보임.
* 좌표 = 경위도 손 고침 → 작업 좌표(TM) · 그 자리 노선 노드도 같이(`moveNodesAt`).
* ========================================================================== */
import { moveNodesAt, type PlanNetwork, type Vertex } from "./D03_PlanAnalysis_Plan";
import { lonLatToTm, type TmParams } from "./D03_PlanAnalysis_Tm";
/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */
export const MAX_POINTS = 40;
/** 보이는 이름 — 빈 값 = 「지점 N」. */
export function pointLabel(names: string[], index: number): string {
return names[index]?.trim() || `지점 ${index + 1}`;
}
/** 경위도 글 → 값 · 못 읽거나 범위 밖이면 까닭 글. */
export function parseLonLat(lonText: string, latText: string): [number, number] | string {
const lon = Number(lonText.trim());
const lat = Number(latText.trim());
if (!lonText.trim() || !Number.isFinite(lon) || Math.abs(lon) > 180) return "경도는 −180 ~ 180";
if (!latText.trim() || !Number.isFinite(lat) || Math.abs(lat) > 90) return "위도는 −90 ~ 90";
return [lon, lat];
}
export interface PointsState {
points: Vertex[];
names: string[];
network: PlanNetwork;
}
/** 지점 더함 — 이름 비움. 상한이면 그대로. */
export function addPoint(state: PointsState, at: Vertex): PointsState {
if (state.points.length >= MAX_POINTS) return state;
return {
...state,
points: [...state.points, [at[0], at[1]]],
names: [...state.names, ""],
};
}
/** 이름 바꿈 — 빈 값 허용. */
export function renamePoint(state: PointsState, index: number, name: string): PointsState {
const names = state.points.map((_, k) => state.names[k] ?? "");
names[index] = name.trim();
return { ...state, names };
}
/** 경위도로 옮김 — 그 자리 노선 노드도 같이. */
export function movePointTo(
state: PointsState,
index: number,
tm: TmParams,
lon: number,
lat: number,
): PointsState {
const to = lonLatToTm(tm, lon, lat);
const from = state.points[index];
return {
...state,
points: state.points.map((p, k): Vertex => (k === index ? to : p)),
network: moveNodesAt(state.network, from, to),
};
}
/** 선 이름 바꿈(597-3) — 빈 값 = 차례 이름으로 돌아감(`name` 비움). */
export function setLineName(network: PlanNetwork, lineId: string, name: string): PlanNetwork {
const text = name.trim();
return {
...network,
lines: network.lines.map((line) =>
line.id === lineId ? { ...line, name: text || null } : line,
),
};
}
@@ -0,0 +1,287 @@
/* =============================================================================
* D03_PlanAnalysis_Select.ts
* D 예정노선 고름 층 셈(PLAN 598 · 요구 463 9 · 12 · 13·16 · 15) — 노드 → 선(두 노드 사이 토막) → 구역(그은 꺾은선) → 전체.
* 저장 꼴은 그대로(`PlanNetwork`) — 선 = 「구역 id + 토막 차례」로 가리킴 · 낱 자료 없음.
* 토막 지움 = 끝 토막은 끝 점 뺌 · 가운데 토막은 구역이 둘로(뒤쪽 = 새 구역 · 갈래 고리 · 조각이 따라감).
* 지점 ↔ 노드 = 같은 자리(1 mm)로 묶음 — 묶인 노드 · 지점 찾기.
* 노드 판(598-4) 방식 · 값 바꿈 = 앞 노드 기준 같은 방향으로 다시 놓음 · 뒤 노드 그대로(어긋남 ⚠ = 599-2 `nodeOff`).
* DOM · fetch · sessionStorage 없음 · 조각 맞춤은 부르는 쪽(`normalizePieces`).
* ========================================================================== */
import {
compassPoint,
fixedLengthPoint,
fixedSlopePoint,
lineLength,
segmentLength,
type HeightAt,
} from "./D03_PlanAnalysis_Draw";
import {
moveNodesAt,
moveVertex,
piecesInBox,
removeLine,
samePoint,
orderLines,
type NodeRecord,
type PlanLine,
type PlanNetwork,
type PlanPiece,
type Vertex,
type ViewBox,
} from "./D03_PlanAnalysis_Plan";
/** 선(토막) — 구역 id · 토막 차례(꼭짓점 `index` → `index + 1`). */
export interface PlanSegment {
line: string;
index: number;
}
/** 노드 — 구역 id · 꼭짓점 차례. */
export interface PlanNode {
line: string;
vertex: number;
}
const EPS = 1e-6;
const round3 = (value: number): number => Math.round(value * 1000) / 1000;
/** 꺾임점마다 선 위 거리. */
export 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;
}
export const sameSegment = (a: PlanSegment, b: PlanSegment): boolean =>
a.line === b.line && a.index === b.index;
/** 구역 토막 모두. */
export function lineSegments(network: PlanNetwork, line: string): PlanSegment[] {
const entry = network.lines.find((l) => l.id === line);
const count = Math.max(0, (entry?.vertices.length ?? 0) - 1);
return Array.from({ length: count }, (_, index) => ({ line, index }));
}
/** 노선망 토막 모두. */
export const allSegments = (network: PlanNetwork): PlanSegment[] =>
network.lines.flatMap((line) => lineSegments(network, line.id));
/** 토막 꼭짓점 둘(없으면 null). */
export function segmentPath(network: PlanNetwork, seg: PlanSegment): [Vertex, Vertex] | null {
const v = network.lines.find((l) => l.id === seg.line)?.vertices;
return v && seg.index >= 0 && seg.index + 1 < v.length ? [v[seg.index], v[seg.index + 1]] : null;
}
/** 토막 선 위 거리 [시작, 끝](m). */
export function segmentRange(network: PlanNetwork, seg: PlanSegment): [number, number] | null {
const v = network.lines.find((l) => l.id === seg.line)?.vertices;
if (!v || seg.index < 0 || seg.index + 1 >= v.length) return null;
const from = lineLength(v.slice(0, seg.index + 1));
return [from, from + segmentLength(v[seg.index], v[seg.index + 1])];
}
/** 선 위 거리 `atM` 이 든 토막 차례(꺾임점 위 = 뒤 토막 · 끝 = 마지막). */
export function segmentAt(vertices: ReadonlyArray<Vertex>, atM: number): number {
const c = chainages(vertices);
for (let i = 0; i + 1 < c.length; i += 1) if (atM < c[i + 1] - EPS) return i;
return Math.max(0, c.length - 2);
}
/** 노드에서 넓힐 토막 — 뒤 토막(끝 노드 = 앞 토막). */
export function nodeSegment(network: PlanNetwork, node: PlanNode): PlanSegment | null {
const n = network.lines.find((l) => l.id === node.line)?.vertices.length ?? 0;
if (n < 2) return null;
return { line: node.line, index: Math.min(node.vertex, n - 2) };
}
/** 토막에 걸친 조각 id(연도 구간). */
export function segmentPieces(network: PlanNetwork, segs: ReadonlyArray<PlanSegment>): string[] {
const out = new Set<string>();
for (const seg of segs) {
const range = segmentRange(network, seg);
if (!range) continue;
for (const piece of network.pieces)
if (piece.line === seg.line && piece.from_m < range[1] - EPS && piece.to_m > range[0] + EPS)
out.add(piece.id);
}
return [...out];
}
/** 끌어 고르기 — 토막마다 판정(창 = 두 끝 모두 안 · 걸침 = 상자와 만남 · `piecesInBox` 와 같은 셈). */
export function segmentsInBox(
network: PlanNetwork,
toView: (point: Vertex) => Vertex,
box: ViewBox,
crossing: boolean,
): PlanSegment[] {
const segs = allSegments(network);
const probe: PlanNetwork = {
lines: segs.map((seg, i) => ({
id: `s${i}`,
vertices: segmentPath(network, seg)!,
branch: null,
})),
pieces: segs.map((seg, i) => {
const [a, b] = segmentPath(network, seg)!;
return { id: String(i), line: `s${i}`, from_m: 0, to_m: segmentLength(a, b), year: 0 };
}),
};
return piecesInBox(probe, toView, box, crossing).map((i) => segs[Number(i)]);
}
/** 새 구역 id — `line-<n>`(있는 것과 안 겹침). */
function nextLineId(lines: ReadonlyArray<PlanLine>): string {
const used = new Set(lines.map((line) => line.id));
let n = lines.length;
while (used.has(`line-${n}`)) n += 1;
return `line-${n}`;
}
/** 새 조각 id — `p<n>`(있는 것 중 가장 큰 수 + 1). */
function nextPieceId(pieces: ReadonlyArray<PlanPiece>): string {
const used = pieces.map((piece) => Number(/^p(\d+)$/.exec(piece.id)?.[1] ?? 0));
return `p${Math.max(0, ...used) + 1}`;
}
/** 꼭짓점 [a, b) 만 남긴 선 — 노드 기록(594-1)도 같이 자름 · 뒤쪽 첫 기록 = 없음(앞 노드가 사라짐). */
function cutVertices(entry: PlanLine, a: number, b: number): PlanLine {
const vertices = entry.vertices.slice(a, b);
if (!entry.nodes) return { ...entry, vertices };
const nodes = entry.nodes.slice(a, b);
if (a > 0 && nodes.length) nodes[0] = null;
return { ...entry, vertices, nodes };
}
/** 토막 하나 지움 — 하나뿐 = 구역 지움 · 끝 토막 = 끝 점 뺌 · 가운데 = 구역 둘(뒤쪽 = 새 구역 · 갈래 아님) ·
* 사라지는 노드에 갈래가 붙었으면 못 지움(까닭 글) · 조각 id 는 앞쪽이 지님(걸친 조각은 뒤쪽 새 id). */
export function removeSegment(network: PlanNetwork, seg: PlanSegment): PlanNetwork | string {
const li = network.lines.findIndex((l) => l.id === seg.line);
const entry = network.lines[li];
const n = (entry?.vertices.length ?? 0) - 1;
if (!entry || seg.index < 0 || seg.index >= n) return "고른 선이 없습니다.";
if (n === 1) return removeLine(network, li);
const i = seg.index;
const keepFront = i > 0;
const keepBack = i < n - 1;
const children = network.lines.filter((l) => l.branch?.line === entry.id);
if (
children.some(
(l) => (!keepFront && l.branch!.vertex === 0) || (!keepBack && l.branch!.vertex === n),
)
)
return "갈래가 붙은 점이 사라집니다 — 갈래를 먼저 지우세요.";
const backId = keepFront ? nextLineId(network.lines) : entry.id;
const c = chainages(entry.vertices);
const [cut0, cut1] = [c[i], c[i + 1]];
const front: PlanPiece[] = [];
const back: PlanPiece[] = [];
for (const piece of network.pieces.filter((p) => p.line === entry.id)) {
if (keepFront && piece.from_m < cut0 - EPS)
front.push({ ...piece, to_m: round3(Math.min(piece.to_m, cut0)) });
if (keepBack && piece.to_m > cut1 + EPS)
back.push({
...piece,
line: backId,
from_m: round3(Math.max(0, piece.from_m - cut1)),
to_m: round3(piece.to_m - cut1),
});
}
const others = network.pieces.filter((p) => p.line !== entry.id);
for (const piece of back)
if (front.some((p) => p.id === piece.id))
piece.id = nextPieceId([...others, ...front, ...back]);
const lines: PlanLine[] = [];
for (const line of network.lines) {
if (line === entry) {
if (keepFront) lines.push(cutVertices(entry, 0, i + 1));
if (keepBack) lines.push({ ...cutVertices(entry, i + 1, n + 1), id: backId, branch: null });
continue;
}
const b = line.branch;
if (b?.line === entry.id && b.vertex > i)
lines.push({ ...line, branch: { line: backId, vertex: b.vertex - (i + 1) } });
else lines.push(line);
}
return { ...network, lines: orderLines(lines), pieces: [...others, ...front, ...back] };
}
/** 토막 여럿 지움 — 구역마다 뒤 토막부터(앞 토막 차례가 안 바뀜) · 하나라도 못 지우면 까닭 글. */
export function removeSegments(
network: PlanNetwork,
segs: ReadonlyArray<PlanSegment>,
): PlanNetwork | string {
let next = network;
const order = [...segs].sort((a, b) =>
a.line === b.line ? b.index - a.index : a.line < b.line ? -1 : 1,
);
for (const seg of order) {
const out = removeSegment(next, seg);
if (typeof out === "string") return out;
next = out;
}
return next;
}
/** 전체 지움 — 선 · 조각 모두(지점은 부르는 쪽이 남김). */
export const removeAll = (network: PlanNetwork): PlanNetwork => ({
...network,
lines: [],
pieces: [],
});
/** `point` 자리 노드 모두(갈래 첫 점 = 붙은 선 점이 대신 · 빼고). */
export function nodesAt(network: PlanNetwork, point: Vertex): PlanNode[] {
const out: PlanNode[] = [];
for (const line of network.lines)
line.vertices.forEach((v, vertex) => {
if (!(vertex === 0 && line.branch) && samePoint(v, point))
out.push({ line: line.id, vertex });
});
return out;
}
/** `from` 자리 지점을 모두 `to` 로(지점 ↔ 노드 따라감). */
export function movePointsAt(points: ReadonlyArray<Vertex>, from: Vertex, to: Vertex): Vertex[] {
return points.map((p): Vertex => (samePoint(p, from) ? [to[0], to[1]] : p));
}
/** 노드 판(598-4) — `vertex` 노드 기록을 `record` 로 · 앞 노드 기준 지금 방향으로 다시 놓음(자유 = 자리 그대로) ·
* 뒤 노드 그대로 · 그 자리 지점 · 같은 자리 노드도 같이 · 첫 노드 · 자리 못 찾음 = 까닭 글. */
export function placeNode(
network: PlanNetwork,
points: ReadonlyArray<Vertex>,
line: number,
vertex: number,
record: NodeRecord,
heightAt: HeightAt,
): { network: PlanNetwork; points: Vertex[] } | string {
const entry = network.lines[line];
if (!entry || vertex < 1 || vertex >= entry.vertices.length) return "첫 노드는 방식이 없습니다.";
const [prev, here] = [entry.vertices[vertex - 1], entry.vertices[vertex]];
const { kind, length_m: lengthM, slope_pct: slopePct } = record;
const to =
kind === "free"
? here
: kind === "length"
? fixedLengthPoint(prev, here, lengthM ?? 0)
: kind === "slope"
? fixedSlopePoint(prev, here, slopePct ?? 0, heightAt)
: compassPoint(prev, here, lengthM ?? 0, slopePct ?? 0, heightAt);
if (!to) return "그 값으로 놓을 자리가 없습니다(지형 · 길이를 보세요).";
const bound = points.some((p) => samePoint(p, here));
let next = bound ? moveNodesAt(network, here, to) : moveVertex(network, line, vertex, to);
next = {
...next,
lines: next.lines.map((l) => {
if (l.id !== entry.id) return l;
const nodes = l.nodes ? [...l.nodes] : l.vertices.map(() => null);
nodes[vertex] = record;
return { ...l, nodes };
}),
};
return { network: next, points: bound ? movePointsAt(points, here, to) : [...points] };
}
@@ -0,0 +1,98 @@
/* =============================================================================
* D03_PlanAnalysis_SlopeBands.ts
* D03 급경사 범위(PLAN 603 · 요구 466 · 467 · 468) — 기울기 % 범위 · 색 · 비율 셈(화면 · 서버 없이).
* 서버 값 그림 칸 = 기울기 % 내림(0~250 · 250 = 250 % 이상 · `D03_PlanAnalysis_Layers.slope_values_png`) ·
* 칸 v 는 가운데(v + 0.5)가 [아래, 위) 안이면 그 범위(위 = 250 이면 250 % 이상도) · 앞 범위 먼저.
* 기본 = 지식DB 15 · 35 · 40° 의 % — `임도망_편성.md:20` · `타당성평가.md:59` · `노선_배치.md:29`.
* 부르는 곳 = `D03_PlanAnalysis_UI_SlopeBands.ts` · `D03_PlanAnalysis_UI_LayerLegend.ts`.
* ========================================================================== */
export interface SlopeBand {
/** 아래 % (포함) */
low: number;
/** 위 % (뺌 · 250 = 끝까지) */
high: number;
/** 그림 색 이름(`--draw-map-*`) — `SLOPE_COLORS` 중 하나 */
color: string;
}
export const SLOPE_MAX_PCT = 250;
export const SLOPE_EMPTY = 255;
export const SLOPE_MAX_BANDS = 8;
/** 고를 색(이름 · 그림 색 값). */
export const SLOPE_COLORS: Array<{ name: string; color: string }> = [
{ name: "노랑", color: "--draw-map-landslide-3" },
{ name: "주황", color: "--draw-map-landslide-2" },
{ name: "빨강", color: "--draw-map-landslide-1" },
{ name: "연두", color: "--draw-map-landslide-4" },
{ name: "초록", color: "--draw-map-landslide-5" },
{ name: "하늘", color: "--draw-map-stream" },
{ name: "파랑", color: "--draw-map-zone-water" },
{ name: "보라", color: "--draw-map-zone-heritage" },
{ name: "분홍", color: "--draw-map-parcel" },
];
export const DEFAULT_SLOPE_BANDS: SlopeBand[] = [
{ low: 26.8, high: 70.0, color: "--draw-map-landslide-3" },
{ low: 70.0, high: 83.9, color: "--draw-map-landslide-2" },
{ low: 83.9, high: SLOPE_MAX_PCT, color: "--draw-map-landslide-1" },
];
export const pctToDeg = (pct: number): number => (Math.atan(pct / 100) * 180) / Math.PI;
/** 범위 고칠 거리 — 없으면 null(빈 값 · 0~250 밖 · 아래 ≥ 위 · 겹침 · 없음 · 너무 많음). */
export function checkBands(bands: SlopeBand[]): string | null {
if (!bands.length) return "범위가 하나는 있어야 합니다";
if (bands.length > SLOPE_MAX_BANDS) return `범위는 ${SLOPE_MAX_BANDS}개까지입니다`;
for (const [i, band] of bands.entries()) {
const at = `${i + 1}번째 범위`;
if (!Number.isFinite(band.low) || !Number.isFinite(band.high)) return `${at} — 값이 비었습니다`;
if (band.low < 0 || band.high > SLOPE_MAX_PCT) return `${at} — 0~${SLOPE_MAX_PCT} % 안으로`;
if (band.low >= band.high) return `${at} — 아래가 위보다 작아야 합니다`;
if (!SLOPE_COLORS.some((c) => c.color === band.color)) return `${at} — 색을 고르세요`;
}
const sorted = [...bands].sort((a, b) => a.low - b.low);
for (let i = 1; i < sorted.length; i++)
if (sorted[i].low < sorted[i - 1].high)
return `범위가 겹칩니다 — ${sorted[i - 1].low}~${sorted[i - 1].high} % 와 ${sorted[i].low}~${sorted[i].high} %`;
return null;
}
/** 저장 글 → 범위(깨졌거나 맞지 않으면 기본). */
export function parseBands(raw: string | null): SlopeBand[] {
try {
const got = JSON.parse(raw ?? "null") as unknown;
if (Array.isArray(got)) {
const bands = got.map((b: Partial<SlopeBand>) => ({
low: Number(b?.low),
high: Number(b?.high),
color: String(b?.color),
}));
if (!checkBands(bands)) return bands;
}
} catch {
// 깨진 값 = 기본
}
return DEFAULT_SLOPE_BANDS.map((b) => ({ ...b }));
}
/** 칸 값 v(0~255) → 범위 차례(없으면 −1). */
export function bandOf(v: number, bands: SlopeBand[]): number {
if (v > SLOPE_MAX_PCT) return -1;
const mid = v + 0.5;
return bands.findIndex(
(b) => b.low <= mid && (mid < b.high || (b.high >= SLOPE_MAX_PCT && v === SLOPE_MAX_PCT)),
);
}
/** 1 % 칸 수 → 범위별 비율(유효 칸 대비). */
export function bandRatios(counts: number[], bands: SlopeBand[]): number[] {
const total = counts.reduce((sum, n) => sum + n, 0) || 1;
const out = bands.map(() => 0);
counts.forEach((n, v) => {
const i = bandOf(v, bands);
if (i >= 0) out[i] += n;
});
return out.map((n) => n / total);
}
+36 -3
View File
@@ -1,5 +1,6 @@
"""D 예정노선 지형 받기(PLAN 530-1 · 561-2) — 사업 전체 한 벌 · 범위 보기 = 내려받기 없음 ·
옛 도엽 유지 · 새 도엽만 · 범위는 줄지 않음 · 빠짐 없음.
"""D 예정노선 지형 받기(PLAN 530-1 · 561-2 · 595) — 사업 전체 한 벌 · 범위 보기 = 내려받기 없음 ·
옛 도엽 유지 · 새 도엽만 · 범위는 줄지 않음 · 빠짐 없음 · 지적도 뺌 · 새로(reset) = 옛 범위 버림 ·
받은 범위 좌표계 · 칸 상한 · 거리.
./venv/Scripts/python.exe -m pytest resources/tester/test_530_1_plan_terrain.py -q
"""
@@ -54,7 +55,6 @@ def fake(tmp_path, monkeypatch):
monkeypatch.setattr(terrain, "parse_and_merge_sheets", merge)
monkeypatch.setattr(terrain, "download_vworld_satellite_map", satellite)
monkeypatch.setattr(terrain, "build_sheet_surface_model", dem)
monkeypatch.setattr(terrain.layers, "ensure_cadastral", lambda *a: {"count": 0}) # 569-1
return tmp_path, seen
@@ -104,6 +104,39 @@ def test_one_set_and_extend_keeps_old(fake) -> None:
assert len(seen["merge"]) == merges and third["box_m"] == grown
def test_reset_drops_old_box_sheets_satellite(fake) -> None:
"""595-2 — 새로 = 옛 받은 범위 · 병합 도엽 · 위성 범위 버림 · 좌표계 · 가운데 기록 ·
다른 좌표계로 물으면 받은 범위 없음 · 지적도는 지형 일에 없음."""
root, seen = fake
terrain.ensure_plan_terrain(root, [A, B], EPSG, center=(127.5, 36.0))
old = set(seen["merge"][-1])
far = [(A[0] + 40000.0, A[1]), (A[0] + 41000.0, A[1] + 1000.0)]
got = terrain.ensure_plan_terrain(root, far, EPSG, reset=True, center=(128.0, 36.0))
m = terrain.DEFAULT_MARGIN_M
assert got["box_m"] == pytest.approx(
[far[0][0] - m, far[0][1] - m, far[1][0] + m, far[1][1] + m]
)
assert not old & set(seen["merge"][-1]) # 옛 도엽 병합에서 빠짐(zip 은 남음)
assert seen["satellite"][-1]["x"][0] > A[0] + 30000 # 옛 위성 범위와 안 합침
assert "cadastral" not in got
meta = terrain.received_meta(root)
assert meta["epsg"] == EPSG and meta["center"] == [128.0, 36.0]
assert terrain.received_box(root, EPSG) == pytest.approx(tuple(got["box_m"]))
assert terrain.received_box(root, 5187) is None # 다른 띠 = 받은 범위 아님
# 새로 아닌 이어 받기 = 가운데 기록 그대로
terrain.ensure_plan_terrain(root, far, EPSG)
assert terrain.received_meta(root)["center"] == [128.0, 36.0]
def test_cells_distance_and_limit(fake) -> None:
root, _ = fake
assert terrain.cells_of((0.0, 0.0, 2000.0, 2000.0)) == 1_000_000 # 2 m 격자
assert terrain.distance_to_box((5.0, 5.0), (0.0, 0.0, 10.0, 10.0)) == 0
assert terrain.distance_to_box((13.0, 14.0), (0.0, 0.0, 10.0, 10.0)) == 5
assert terrain.MAX_CELLS == 5_000_000 and terrain.FAR_M == 5000
assert terrain.plan_extent(root, [A, B], EPSG)["max_cells"] == terrain.MAX_CELLS
def test_extent_no_download_and_covered(fake) -> None:
root, seen = fake
before = terrain.plan_extent(root, [A, B], EPSG)
+159 -4
View File
@@ -1,5 +1,6 @@
"""D 지형 API(PLAN 530-1 · 561-2 · 591-2) — 범위 = 지점(설계 내용 파일) 상자 + 여유 · 지점 · 선이
없으면 등록 기준점 둘레 ± 1 km · 받기 = 한 벌.
"""D 지형 API(PLAN 530-1 · 561-2 · 591-2 · 595) — 범위 = 지점(설계 내용 파일) 상자 + 여유 · 지점 ·
선이 없으면 등록 기준점 둘레 ± 1 km · 받기 = 한 벌 · 위치 칸 가운데 = 둘레 덧 · 멀다 · 다른 띠
물음 · 새로(reset) · 칸 상한 413 · 서버 다시 켜짐 = 끊김.
옛 분석 API 시험(GET · PUT analysis · 꺾임점 고침)은 옛 추천 엔진과 함께 걷음(PLAN 562-2) ·
노선 정본 시험 = `test_530_2_plan_analyze.py` · 지형 셈 시험 = `test_530_1_plan_terrain.py`.
@@ -20,6 +21,8 @@ from D03_PlanAnalysis import D03_PlanAnalysis_Router as router_mod
PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2"
P0, P2 = (0.0, 0.0), (900.0, 400.0)
URL = f"/api/projects/{PID}/plan/terrain"
OX, OY = 200000.0, 500000.0 # 595 시험 = 중부 띠 안(경도 127°)
Q0, Q2 = (OX + P0[0], OY + P0[1]), (OX + P2[0], OY + P2[1])
class _Pool:
@@ -51,6 +54,7 @@ def client(tmp_path, monkeypatch):
test = TestClient(app)
test.plan = plan
test.info = info
test.root = tmp_path
return test
@@ -90,14 +94,14 @@ def test_post_runs_one_set(client, monkeypatch) -> None:
saved: list = []
works: list = []
def ensure(root, points, epsg, margin_m, report):
def ensure(root, points, epsg, margin_m, report, **kw):
calls.append((points, epsg, margin_m))
return {"sheets": {"downloaded": ["1"]}, "models": [{"model_file_path": "m"}]}
async def save(project_id, models, epsg):
saved.append((models, epsg))
def start(project_id, kind, work):
def start(project_id, kind, work, root=None):
works.append(work)
return {"status": "accepted", "kind": kind}
@@ -116,3 +120,154 @@ def test_post_runs_one_set(client, monkeypatch) -> None:
# 옛 화면 꼴(points 몸) — 여유 처음 값 · 전체 시점 · 종점
post({"points": []})
assert calls[-1] == ([P0, P2], 5186, None)
# ── 595 위치 바꿈 · 상한 · 끊김 ──
@pytest.fixture
def posting(client, monkeypatch):
"""POST 받기 — 일은 바로 돌림 · ensure 부름(점 · 좌표계 · reset · center) 기록."""
calls: list = []
def ensure(root, points, epsg, margin_m, report, reset=False, center=None):
calls.append({"points": points, "epsg": epsg, "reset": reset, "center": center})
return {"sheets": {"downloaded": []}, "models": []}
async def save(project_id, models, epsg):
return None
works: list = []
def start(project_id, kind, work, root=None):
works.append(work)
return {"status": "accepted", "kind": kind}
def post(body):
res = client.post(URL, json=body)
while works: # 일은 응답 뒤 돌림(앱 고리 밖)
asyncio.run(works.pop()(lambda percent, message: None))
return res
monkeypatch.setattr(router_mod.terrain, "ensure_plan_terrain", ensure)
monkeypatch.setattr(router_mod, "_save_models", save)
monkeypatch.setattr(router_mod, "_start_job", start)
client.plan["points"] = [{"x": Q0[0], "y": Q0[1]}, {"x": Q2[0], "y": Q2[1]}]
client.calls = calls
client.run_post = post
return client
def _have(client, box, epsg=5186, center=None):
meta = {"box": list(box), "margin_m": 350.0, "epsg": epsg}
if center:
meta["center"] = list(center)
folder = router_mod.terrain.models_dir_of(client.root)
folder.mkdir(parents=True, exist_ok=True)
(folder / router_mod.terrain.TERRAIN_META).write_text(json.dumps(meta), "utf-8")
def test_center_near_adds_site_ring(posting) -> None:
"""가까운 위치 바꿈 = 지점 + 새 가운데 둘레 · 받은 범위와 합침."""
from common_util.common_util_crs import lonlat_to_tm, tm_to_lonlat
_have(posting, (OX - 500.0, OY - 500.0, OX + 1500.0, OY + 1000.0))
lon, lat = tm_to_lonlat(OX + 3000.0, OY, 5186) # 받은 범위에서 1.5 km
res = posting.run_post({"center": {"lon": lon, "lat": lat}}).json()
assert res["status"] == "accepted"
call = posting.calls[-1]
x, y = lonlat_to_tm(lon, lat, 5186)
r = router_mod.terrain.SITE_RADIUS_M - router_mod.terrain.DEFAULT_MARGIN_M
assert call["points"][:2] == [Q0, Q2] and call["points"][2] == pytest.approx((x - r, y - r))
assert call["reset"] is False and call["center"] == (lon, lat)
# extent 에도 캐시 가운데 = 둘레가 상자에 들어감
got = posting.get(f"{URL}/extent", params={"lon": lon, "lat": lat}).json()
assert got["box_m"][2] == pytest.approx(x + router_mod.terrain.SITE_RADIUS_M)
def test_center_far_asks_then_reset(posting) -> None:
"""5 km 넘음 = 「far」(받지 않음) · reset = 옛 범위 버리고 새 ±1 km(지점 안 넣음)."""
from common_util.common_util_crs import tm_to_lonlat
_have(posting, (OX - 500.0, OY - 500.0, OX + 1500.0, OY + 1000.0))
lon, lat = tm_to_lonlat(OX + 8000.0, OY, 5186) # 6.5 km
body = {"center": {"lon": lon, "lat": lat}}
far = posting.run_post(body).json()
assert far["status"] == "far" and far["distance_m"] == pytest.approx(6500, abs=2)
assert "km" in far["message"] and posting.calls == []
res = posting.run_post({**body, "reset": True}).json()
assert res["status"] == "accepted"
call = posting.calls[-1]
assert call["reset"] is True and len(call["points"]) == 2 # 둘레 두 귀만
assert call["points"][0][0] == pytest.approx(OX + 8000 - 650, abs=1)
def test_center_other_band_asks_then_moves(posting) -> None:
"""다른 TM 띠 = 「band」 · 확인(reset) 뒤 그 띠 · 그 뒤엔 받은 지형 띠를 따름."""
posting.plan["lines"] = [{"id": "a"}]
body = {"center": {"lon": 129.1017, "lat": 36.8128}} # 용화(동부 5187)
ask = posting.run_post(body).json()
assert ask == {
"status": "band",
"epsg": 5186,
"center_epsg": 5187,
"message": ask["message"],
}
assert posting.calls == []
# 확인 전 extent = 가운데 무시(옛 띠 그대로)
assert posting.get(f"{URL}/extent", params={"lon": 129.1, "lat": 36.8}).json()["epsg"] == 5186
posting.run_post({**body, "reset": True})
call = posting.calls[-1]
assert call["epsg"] == 5187 and call["reset"] is True and len(call["points"]) == 2
# 받은 뒤([저장] 전) — 가운데 없이 물어도 받은 지형 띠 · 가운데 둘레
(x0, y0), (x1, y1) = call["points"]
box = [x0 - 400, y0 - 400, x1 + 400, y1 + 400]
_have(posting, box, epsg=5187, center=(129.1017, 36.8128))
got = posting.get(f"{URL}/extent").json()
assert got["epsg"] == 5187 and got["covered"] is True
def test_cells_limit_413(posting) -> None:
posting.plan["points"].append(
{"x": OX + 6000.0, "y": OY + 6000.0}
) # 6.7 × 6.7 km = 1,100 만 칸
res = posting.run_post({})
assert res.status_code == 413 and "상한 500만" in res.json()["detail"]
assert posting.calls == []
def test_progress_interrupted_after_restart(client, monkeypatch) -> None:
"""도는 기록이 남았는데 기억에 없음(서버 다시 켜짐) = 끊김 · 받은 적 없음 = percent 100."""
root = client.root
async def tile_root(project_id):
return root
monkeypatch.setattr(router_mod, "_tile_root", tile_root)
url = f"/api/projects/{PID}/plan/progress"
router_mod._JOBS.clear()
idle = client.get(url).json()["terrain"]
assert idle == {**router_mod.IDLE}
path = router_mod._job_path(root, "terrain")
seen = []
async def work(report):
report(65, "5/5 사업 지형면 만드는 중")
seen.append(json.loads(path.read_text("utf-8")))
async def run():
router_mod._start_job(PID, "terrain", work, root)
await asyncio.sleep(0.05)
asyncio.run(run())
assert seen[0]["running"] is True and seen[0]["percent"] == 65 # 도는 동안 기록
assert json.loads(path.read_text("utf-8"))["running"] is False # 끝 기록
path.write_text(json.dumps(seen[0]), "utf-8") # 도는 중 꺼짐
router_mod._JOBS.clear() # 다시 켜짐 = 기억 빔
got = client.get(url).json()["terrain"]
assert got["running"] is False and got["interrupted"] is True
assert got["percent"] == 65 and got["message"] is None and got["error"] is None
# 끝난 기록 = 끊김 아님
path.write_text(json.dumps({**seen[0], "running": False}), "utf-8")
assert client.get(url).json()["terrain"] == {**router_mod.IDLE}
+17 -5
View File
@@ -17,10 +17,11 @@ def _no_hex(path: str) -> None:
def test_d03_points_parts():
"""591-2 — D02 걷음 · 지점 칸 = 목록 줄 · 아이콘 단추 · 우클릭 메뉴 부품."""
"""591-2 — D02 걷음 · 지점 칸 = 목록 줄 · 아이콘 단추 · 우클릭 메뉴 부품(598-2 = `UI_Menu`)."""
assert not (ROOT / "D02_PlanBase" / "D02_PlanBase_UI_Page.ts").exists()
ts = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts")
assert "createListRow(" in ts and "createIconButton(" in ts and "createMapContextMenu(" in ts
assert "createListRow(" in ts and "createIconButton(" in ts
assert "createMapContextMenu(" in _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts")
for raw in ('createElement("button")', 'createElement("input")', 'el("input"'):
assert raw not in ts
@@ -35,6 +36,13 @@ def test_d03_parts():
layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts
assert "createProgressCircle({ size: 40, corner: true })" in ts # 596-3 받기 = 지도 귀퉁이 서클
site = _src(
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts"
) # 596-2 위치 칸 · 596-5 등록 찾기 = 같은 부품
assert "createFindList(" in site and "searchPlaces(" in site and "createPlanSite(" in ts
assert "createPlaceFind(" in _src("D01_PlanRegister/D01_PlanRegister_UI_Page.ts")
for raw in ('createElement("button")', 'createElement("input")'):
assert raw not in site, raw
# 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품
for src in (ts, draw, draw_panel, tools, layers):
for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'):
@@ -87,10 +95,14 @@ def test_d03_parts():
# 599-4 — 연도 구역 마커 = 그리기 칸 단추(끌어 놓기) · 지도 도구줄에서 뺌
assert "YEAR_MARK_DRAG" in draw_panel and '"year-mark"' not in tools
props = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts") # 585-2 조각 연도 = 속성 판
assert "createSelectField(" in props and "createFloatPanel(" in props
assert "createSelectField(" in props and "createFloatPanel(" in props and "createKv(" in props
assert 'createElement("button")' not in props and 'createElement("select")' not in props
assert 'createElement("select")' not in three and "createSelectField" not in three # 601 선 고름 걷음
assert "createCheckbox" not in three and "setCorridors(" in three # 601 노선망 예상 코리도 한 번에
assert (
'createElement("select")' not in three and "createSelectField" not in three
) # 601 선 고름 걷음
assert (
"createCheckbox" not in three and "setCorridors(" in three
) # 601 노선망 예상 코리도 한 번에
for dead in (".d03-plan__bar", ".d03-plan__mode", "is-picked"):
assert dead not in css, dead
assert "b05-route__3d-modal" not in three and "b05-route__3d-body" not in three # 빌림 끊음
+1 -1
View File
@@ -113,7 +113,7 @@ def test_de_pages_guard_leave():
if name:
assert f'discard: () => clearState("{name}", projectId)' in call, path
# D03 — 못 읽은 채(프로젝트 없음 · 불러오기 실패) 떠나면 고침 없음
assert "let savedJson = JSON.stringify({ network, points });" in _src(
assert "let savedJson = JSON.stringify({ network, points, pointNames, site });" in _src(
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts"
)
e03 = _src("E03_StabSection/E03_StabSection_UI_Page.ts")
+37 -15
View File
@@ -1,5 +1,5 @@
"""D 노선분석 지도 겹(PLAN 569-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 ·
겹친 필지 거름 · 급경사 15° · 35° · 40° 단계 · 격자 지문 같으면 다시 안 셈.
"""D 노선분석 지도 겹(PLAN 569-1 · 595-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 ·
겹친 필지 거름 · 실패 조각만 다시 · 급경사 % 값 그림 · 1 % 칸 수(603) · 격자 지문 같으면 다시 안 셈.
./venv/Scripts/python.exe -m pytest resources/tester/test_569_1_plan_layers.py -q
"""
@@ -127,9 +127,29 @@ def test_cadastral_failures(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=1))
first = layers.ensure_cadastral(tmp_path, BOX, EPSG)
assert first["failed"] == 1 and (tmp_path / layers.CADASTRAL_FILE).exists()
assert (
len(json.loads((tmp_path / layers.CADASTRAL_KEY).read_text("utf-8"))["failed_tiles"]) == 1
)
# 595-1 — 실패 조각만 다시 · 필지는 옛 것 + 그 조각 = 통째로 받은 것과 같음
again = FakeWfs()
monkeypatch.setattr(layers, "request_vworld_wfs", again)
assert layers.ensure_cadastral(tmp_path, BOX, EPSG)["fetched"] and again.calls == 15
retry = layers.ensure_cadastral(tmp_path, BOX, EPSG)
assert retry["fetched"] and again.calls == 1 and retry["failed"] == 0
assert retry["count"] == 15 * 2 + 1
assert not layers.ensure_cadastral(tmp_path, BOX, EPSG)["fetched"] and again.calls == 1
# 옛 열쇠(실패 조각 기록 없음) = 통째로
key = json.loads((tmp_path / layers.CADASTRAL_KEY).read_text("utf-8"))
key.update(failed=1)
key.pop("failed_tiles")
(tmp_path / layers.CADASTRAL_KEY).write_text(json.dumps(key), "utf-8")
layers.ensure_cadastral(tmp_path, BOX, EPSG)
assert again.calls == 1 + 15
# 다시 받기도 모두 실패 = 옛 필지 그대로 · 실패 조각 남김(오류 아님)
key.update(failed=1, failed_tiles=[list(layers.tiles(BOX)[0])])
(tmp_path / layers.CADASTRAL_KEY).write_text(json.dumps(key), "utf-8")
monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=99))
kept = layers.ensure_cadastral(tmp_path, BOX, EPSG)
assert kept["failed"] == 1 and kept["count"] == 15 * 2 + 1
# ── 급경사 ──
@@ -149,20 +169,22 @@ def _indices(value) -> np.ndarray:
return np.asarray(Image.open(io.BytesIO(raw)))
def test_slope_bands_and_north_up() -> None:
def test_slope_values_and_north_up() -> None:
"""값 그림(603) — 칸 = 기울기 % 내림 · 위 = 북 · 빈 칸 · 끝 줄 = 255 · 칸 수 합 = 유효 칸."""
z = _plane(10.0, 37.0)
value = layers.slope_bands_png(z, np.ones(z.shape, bool), 2.0)
ratios = [b["ratio"] for b in value["bands"]]
assert [b["min_deg"] for b in value["bands"]] == [15.0, 35.0, 40.0]
assert ratios[0] < 0.1 and 0.4 < ratios[1] < 0.6 and ratios[2] < 0.1 # 경계 줄만
value = layers.slope_values_png(z, np.ones(z.shape, bool), 2.0)
index = _indices(value)
assert value["width"] == value["height"] == 40
assert (index[5, 5:35] == 2).all() and (index[-5, 5:35] == 0).all() # 위 = 북 = 37°
steep = layers.slope_bands_png(_plane(45.0, 20.0), np.ones((40, 40), bool), 2.0)
assert steep["bands"][2]["ratio"] > 0.4 and steep["bands"][0]["ratio"] > 0.4
assert value["width"] == value["height"] == 40 and "bands" not in value
assert (index[5, 5:35] == 75).all() and (index[-5, 5:35] == 17).all() # 위 = 북 = 37° = 75 %
assert (index[0] == layers.SLOPE_EMPTY).all() # 격자 끝 = 셈 못 함
assert sum(value["counts"]) == int((index != layers.SLOPE_EMPTY).sum()) == 38 * 38
assert len(value["counts"]) == layers.SLOPE_MAX_PCT + 1
cliff = layers.slope_values_png(_plane(80.0, 80.0), np.ones((40, 40), bool), 2.0)
assert cliff["counts"][layers.SLOPE_MAX_PCT] == 38 * 38 # 567 % → 250 % 이상 칸
hole = np.ones(z.shape, bool)
hole[25:30, 10:20] = False
assert (_indices(layers.slope_bands_png(z, hole, 2.0))[-29:-24, 10:20] == 0).all()
holed = _indices(layers.slope_values_png(z, hole, 2.0))
assert (holed[-29:-24, 10:20] == layers.SLOPE_EMPTY).all()
def test_slope_layer_fingerprint(tmp_path, monkeypatch) -> None:
@@ -174,8 +196,8 @@ def test_slope_layer_fingerprint(tmp_path, monkeypatch) -> None:
y = 450000.0 + np.arange(40) * 2.0
np.savez(npz, x=x, y=y, z=z, valid_mask=np.ones(z.shape, bool), resolution=[2.0])
counted = []
real = layers.slope_bands_png
monkeypatch.setattr(layers, "slope_bands_png", lambda *a: counted.append(1) or real(*a))
real = layers.slope_values_png
monkeypatch.setattr(layers, "slope_values_png", lambda *a: counted.append(1) or real(*a))
first = layers.slope_layer(tmp_path, models, EPSG)
assert first["box_m"] == [199999.0, 449999.0, 200079.0, 450079.0] and len(first["box"]) == 4
assert layers.slope_layer(tmp_path, models, EPSG) == first and len(counted) == 1
@@ -200,7 +200,7 @@ def test_hillshade_saved_apart_from_slope(tmp_path) -> None:
shade = layers.slope_layer(tmp_path, models, EPSG, "hillshade")
slope = layers.slope_layer(tmp_path, models, EPSG, "slope")
assert shade["image"].startswith("data:image/png;base64,") and shade["bands"] == []
assert slope["bands"] and shade["box_m"] == slope["box_m"]
assert slope["counts"] and shade["box_m"] == slope["box_m"] # 603 = 값 그림 + 칸 수
assert layers.slope_layer(tmp_path, models, EPSG, "hillshade") == shade
@@ -1,5 +1,6 @@
"""D 노선분석 GIS 겹 더함 3(PLAN 584-3) — 올린 임도망도 SHP(zip · 낱 파일 묶음 · prj 없으면 5179 ·
cp949 · 받은 범위로 자름 · 범위 밖 = 받지 않음 · 저장본) · 산사태취약지역 시도 표(머리 · 열 별칭 ·
"""D 노선분석 GIS 겹 더함 3(PLAN 584-3 · 595-4) — 올린 임도망도 SHP(zip · 낱 파일 묶음 · prj 없으면
5179 · cp949 · 기준점 ±5 km ∪ 받은 지형으로 자름 · 잘린 상자 · 기준점 거리 · 범위 밖 = 받지 않음 +
거리 · 저장본) · 산사태취약지역 시도 표(머리 · 열 별칭 ·
도분초 · 지번) · 점 · 지적 필지 맞춤 · 표 없는 시도 · 화면 겹 정의.
./venv/Scripts/python.exe -m pytest resources/tester/test_584_3_plan_forest_road_zones.py -q
@@ -81,7 +82,7 @@ def test_bad_uploads_keep_old(tmp_path) -> None:
([("a.zip", b"not a zip")], "zip 파일을 열지"),
([(n, d) for n, d in files if not n.endswith(".dbf")], ".dbf"),
([("a.zip", _zip({"readme.txt": b"x"}))], ".shp 파일이 없습니다"),
(_shp_files(tmp_path / "far", [LINES[2]]), "받은 범위 안에 임도가 없습니다"),
(_shp_files(tmp_path / "far", [LINES[2]]), "받은 지형 밖뿐입니다"),
]
for upload, text in bad:
with pytest.raises(ValueError, match=re.escape(text)):
@@ -91,6 +92,26 @@ def test_bad_uploads_keep_old(tmp_path) -> None:
assert not list((tmp_path / fr.FOLDER).glob("*.new"))
def test_clip_box_site_5km_and_distance(tmp_path) -> None:
"""595-4 — 자르는 범위 = 기준점 ±5 km ∪ 받은 지형 · 잘린 상자 · 가장 가까운 임도 거리 ·
밖뿐 = 거절 + 거리."""
site = (231000.0, 450600.0)
frame = fr.clip_box(site, BOX)
assert frame == (226000.0, 445600.0, 236000.0, 455600.0) # 5 km 상자가 받은 지형을 덮음
assert fr.clip_box(site, (220000.0, 450000.0, 232000.0, 451000.0))[0] == 220000.0 # ∪
assert fr.clip_box(site, None) == frame
got = fr.save_roads(tmp_path, _shp_files(tmp_path / "a", LINES), frame, EPSG, site)
assert (got["count"], got["total"]) == (2, 3) # 범위 밖 한 가닥(25 km)만 빠짐
assert got["length_m"] == pytest.approx(2000 + 300, abs=1) # 받은 지형 밖 반도 들어옴
assert got["distance_m"] == pytest.approx(100, abs=0.5) # 450600 ~ 450500
w, s, e, n = got["bounds"]
assert (w, s) == pytest.approx(tm_to_lonlat(229000, 450100, EPSG), abs=1e-4)
assert (e, n) == pytest.approx(tm_to_lonlat(231500, 450500, EPSG), abs=1e-4)
far = _shp_files(tmp_path / "far", [LINES[2]])
with pytest.raises(ValueError, match=r"가장 가까운 임도 2[0-9]\.[0-9] km\(파일 선 1가닥\)"):
fr.save_roads(tmp_path, far, frame, EPSG, site)
def test_no_upload_is_not_found(tmp_path) -> None:
with pytest.raises(FileNotFoundError):
fr.road_layer(tmp_path, BOX, EPSG)
@@ -255,7 +276,9 @@ def test_screen_defs() -> None:
assert entry and entry.group(2) == "false", key # 처음 값 끔
assert re.search(rf"^\s+{entry.group(1)}:", icons, re.M), key
assert f"--draw-map-{color}:" in theme and f'"--draw-map-{color}"' in page
assert re.search(r"^\s+upload:", icons, re.M) and "pickFiles(" in page # 부품 상자 고르기 창
site = (folder / "D03_PlanAnalysis_UI_Site.ts").read_text("utf-8") # 596-2 올리기 = 위치 칸
assert "pickFiles(" in site and "pickFiles(" not in page # 부품 상자 고르기 창
assert "uploadRoads" in page and "uploadPlanForestRoads(" in page
assert "<input" not in page and 'createElement("input")' not in page
assert paint.count("base: true,") == paint.count("draw: (context, map) =>") # 겹 모두 바탕
assert "임도 짐작" in legend and "올린 임도망도" in legend # 범례 구분
+4 -17
View File
@@ -1,5 +1,5 @@
"""585-2 · 585-3 예정노선 끌어 고르기 판정 · 여러 구간 연도 · 연도 구역 마커 나눔 · 합침 · 갈래 시점 지정
(요구 445 A4~A6 · 446) — TS `logic/D03_PlanAnalysis_Plan.ts`.
"""585-2 · 585-3 예정노선 끌어 고르기 판정 · 여러 구간 연도 · 연도 구역 마커 나눔 · 합침
(요구 445 A4~A6 · 446) — TS `logic/D03_PlanAnalysis_Plan.ts`. 갈래 시점 지정은 걷음(요구 463-15 · PLAN 598-3).
창(오른쪽으로) = 구간 꺾은선 점이 모두 상자 안 · 걸침(왼쪽으로) = 점 하나라도 안 또는 토막이 상자 변과 만남 ·
돌린 화면도 보이는 px 로 셈 · 여러 구간 연도 한 번에.
@@ -46,7 +46,7 @@ writeFileSync(process.argv[2], JSON.stringify({
...marks(),
}));
/** 585-3 — 마커 = 구역 나누기만(해 그대로) · 지움 = 앞 구간이 먹음 · 갈래 처음 해 · 갈래 시점 지정. */
/** 585-3 — 마커 = 구역 나누기만(해 그대로) · 지움 = 앞 구간이 먹음 · 갈래 처음 해. */
function marks() {
const span = (net) => net.pieces.filter((p) => p.line === "main").map((p) => [p.id, p.from_m, p.to_m, p.year]);
let m = plan.newLine(plan.emptyNetwork(), [0, 0]);
@@ -58,17 +58,9 @@ function marks() {
// 갈래 처음 해 = 붙은 자리(60 m · p2 = 2028) 구간 해
let b = plan.normalizePieces(plan.insertVertex(yeared, 0, 0, [60, 0]), Y);
b = plan.normalizePieces(plan.appendVertex(plan.addBranch(b, 0, 1), 1, [60, 50]), Y);
// 갈래 시점 지정 — (60,90) 에서 그어 (60,0) 꺾임점에 닿은 본선 → 닿은 끝이 시점인 갈래
let t = plan.normalizePieces(plan.insertVertex(m, 0, 0, [60, 0]), Y);
t = plan.normalizePieces(plan.appendVertex(plan.newLine(t, [60, 90]), 1, [60, 0]), Y);
const branched = plan.branchFromEnd(t, 1);
const lone = plan.normalizePieces(plan.appendVertex(plan.newLine(m, [0, 50]), 1, [30, 80]), Y);
return {
cut: span(cut), edgeSame: edge === cut, merged: span(merged),
branchYear: b.pieces.filter((p) => p.line !== "main").map((p) => p.year),
branched: typeof branched === "string" ? branched : branched.lines.map((l) => [l.id, l.vertices[0], l.branch]),
again: typeof branched === "string" ? null : plan.branchFromEnd(branched, 1),
lone: plan.branchFromEnd(lone, 1),
};
}
"""
@@ -118,14 +110,9 @@ def test_여러_구간_연도(tmp_path: Path) -> None:
assert years["p1"] == years["p3"] == 2028 and years["p2"] == 2026
def test_마커_구역_나눔_합침_갈래(tmp_path: Path) -> None:
def test_마커_구역_나눔_합침_갈래_해(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
assert got["cut"] == [["p1", 0, 40, 2026], ["p2", 40, 100, 2026]] # 구역만 나눔 · 해 그대로
assert got["edgeSame"] # 다른 마커 1 m 안 = 안 놓음
assert got["merged"] == [["p1", 0, 100, 2026]] # 지움 = 앞 구간 해 · id 가 먹음
assert got["branchYear"] == [2028] # 갈래 처음 해 = 붙은 자리 구간 해
lines = {line_id: (head, branch) for line_id, head, branch in got["branched"]}
head, branch = lines[next(k for k in lines if k != "main")]
assert head == [60, 0] and branch == {"line": "main", "vertex": 1} # 닿은 끝 = 시점
assert got["again"] == "이미 갈래입니다 — 갈라지는 점이 시점입니다."
assert got["lone"].startswith("다른 선에 닿은 끝이 없습니다")
+120
View File
@@ -0,0 +1,120 @@
"""597-2 예정노선 지점 칸 손 고침 계산 — 이름 규칙 · 보이는 이름 · 경위도 읽기 · 더함 · 이름 바꿈 · 경위도로 옮김
(요구 463 7 · 8) — TS `logic/D03_PlanAnalysis_PointEdit.ts`.
./venv/Scripts/python.exe -m pytest resources/tester/test_597_point_edit.py -q
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_PointEdit.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { writeFileSync } from "node:fs";
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
import * as pe from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.js";
import * as tmx from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Tm.js";
const Y = 2026;
const tm = tmx.tmParams(5187);
// 지점 (100,0) 에서 시작하는 선 · 지점 둘
let n = plan.newLine(plan.emptyNetwork(), [100, 0]);
n = plan.normalizePieces(plan.appendVertex(n, 0, [200, 0]), Y);
const state = { points: [[100, 0], [500, 500]], names: ["", "가"], network: n };
const named = pe.setLineName(pe.setLineName(n, n.lines[0].id, ' 본선A '), 'none', 'x');
const cleared = pe.setLineName(named, n.lines[0].id, ' ');
const added = pe.addPoint(state, [7, 8]);
const full = { points: Array.from({ length: 40 }, (_, i) => [i, 0]), names: [], network: n };
const [lon, lat] = tmx.tmToLonLat(tm, 100, 0);
const moved = pe.movePointTo(state, 0, tm, lon + 0.001, lat);
const back = tmx.tmToLonLat(tm, ...moved.points[0]);
writeFileSync(process.argv[2], JSON.stringify({
errors: ["", "가나다", "a/b", "a" + String.fromCharCode(92) + "b", "a:b", "a*b", "a?b", 'a"b', "a<b", "a>b", "a|b", "x".repeat(20), "x".repeat(21)]
.map((s) => plan.nameError(s)),
labels: [pe.pointLabel(["", " ", "가"], 0), pe.pointLabel(["", " ", "가"], 1), pe.pointLabel(["", " ", "가"], 2), pe.pointLabel([], 3)],
parse: [pe.parseLonLat("128.5", "36.9"), pe.parseLonLat("", "36.9"), pe.parseLonLat("128.5", "abc"), pe.parseLonLat("181", "0"), pe.parseLonLat("0", "-91")],
added: [added.points, added.names, added.network === n],
lineName: [named.lines[0].name, plan.lineNames(named.lines), cleared.lines[0].name, plan.lineNames(cleared.lines)],
full: pe.addPoint(full, [1, 1]) === full,
renamed: [pe.renamePoint(state, 1, " 나 ").names, pe.renamePoint({ ...state, names: [] }, 1, "다").names],
moved: { points: moved.points, lines: moved.network.lines.map((l) => l.vertices), back, expect: [lon + 0.001, lat], untouched: moved.points[1], names: moved.names },
}));
"""
@pytest.fixture(scope="module")
def got(tmp_path_factory: pytest.TempPathFactory) -> dict:
tmp_path = tmp_path_factory.mktemp("pe")
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--rootDir", str(ROOT),
"--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler",
"--ignoreConfig"],
cwd=str(ROOT), check=True, capture_output=True,
) # fmt: skip
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
result = tmp_path / "out.json"
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def test_name_rule(got: dict) -> None:
ok, bad = got["errors"][:2], got["errors"][2:11]
assert ok == [None, None] # 빈 이름 · 한글 = 됨
assert all(msg and "쓸 수 없습니다" in msg for msg in bad) # 파일 이름 못 쓰는 글자 아홉
assert got["errors"][11] is None # 20 자
assert "20 자" in got["errors"][12] # 21 자 = 막음
def test_label_blank_falls_back(got: dict) -> None:
assert got["labels"] == ["지점 1", "지점 2", "가", "지점 4"]
def test_parse_lonlat(got: dict) -> None:
ok, no_lon, bad_lat, big_lon, big_lat = got["parse"]
assert ok == [128.5, 36.9]
assert no_lon.startswith("경도") and bad_lat.startswith("위도")
assert big_lon.startswith("경도") and big_lat.startswith("위도")
def test_add_and_limit(got: dict) -> None:
points, names, network_same = got["added"]
assert points == [[100, 0], [500, 500], [7, 8]] and names == ["", "가", ""]
assert network_same # 더해도 노선 그대로
assert got["full"] # 40 개 = 그대로
def test_rename(got: dict) -> None:
assert got["renamed"][0] == ["", "나"] # 앞뒤 빈칸 뺌
assert got["renamed"][1] == ["", "다"] # 이름 목록이 짧아도 차례 맞춤
def test_move_by_lonlat_drags_node(got: dict) -> None:
moved = got["moved"]
assert moved["back"] == pytest.approx(moved["expect"], abs=1e-7) # 경위도 왕복
assert moved["lines"][0][0] == moved["points"][0] # 그 자리 노선 노드도 같이
assert moved["lines"][0][1] == [200, 0] # 다른 노드는 그대로
assert moved["untouched"] == [500, 500] and moved["names"] == ["", "가"]
def test_line_name(got: dict) -> None:
named, names, cleared, back = got["lineName"]
assert named == "본선A" and names == ["본선A"] # 앞뒤 빈칸 뺌 · 이름 우선
assert cleared is None and back == ["본선"] # 빈 값 = 차례 이름으로
@@ -0,0 +1,150 @@
"""598-1 예정노선 고름 층 노드 → 선(토막) → 구역 → 전체 · 토막 지움(요구 463 9 · 12 · 13·16 · 15)
— TS `logic/D03_PlanAnalysis_Select.ts`.
끝 토막 지움 = 끝 점 뺌 · 가운데 토막 지움 = 구역 둘(뒤쪽 = 새 구역 · 갈래 고리 · 조각 따라감) ·
사라지는 노드에 갈래 = 까닭 글 · 끌어 고르기 토막 판정 · 지점 ↔ 노드 같은 자리.
./venv/Scripts/python.exe -m pytest resources/tester/test_598_1_plan_select_layers.py -q
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Select.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { writeFileSync } from "node:fs";
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
import * as sel from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.js";
const Y = 2026;
// 본선 (0,0)→(100,0)→(200,0)→(300,0) · 마크 150 → p1 [0,150] p2 [150,300] · 갈래 at 노드 2 (200,0)→(200,50)
let n = plan.newLine(plan.emptyNetwork(), [0, 0]);
for (const x of [100, 200, 300]) n = plan.appendVertex(n, 0, [x, 0]);
n = plan.normalizePieces(plan.splitPiece(plan.normalizePieces(n, Y), "main", 150), Y);
n = plan.normalizePieces(plan.setPiecesYear(n, ["p2"], 2028), Y);
n = plan.normalizePieces(plan.appendVertex(plan.addBranch(n, 0, 2), 1, [200, 50]), Y);
const show = (net) => typeof net === "string" ? net : ({
lines: net.lines.map((l) => [l.id, l.vertices, l.branch]),
pieces: plan.normalizePieces(net, Y).pieces.map((p) => [p.id, p.line, p.from_m, p.to_m, p.year]),
});
const seg = (line, index) => ({ line, index });
const view = ([x, y]) => [x, -y];
writeFileSync(process.argv[2], JSON.stringify({
segs: sel.allSegments(n),
range: sel.segmentRange(n, seg("main", 1)),
at: [sel.segmentAt(n.lines[0].vertices, 0), sel.segmentAt(n.lines[0].vertices, 100), sel.segmentAt(n.lines[0].vertices, 300)],
nodeSeg: [sel.nodeSegment(n, { line: "main", vertex: 1 }), sel.nodeSegment(n, { line: "main", vertex: 3 })],
pieces: sel.segmentPieces(n, [seg("main", 1)]).sort(),
middle: show(sel.removeSegment(n, seg("main", 0 + 1))),
tail: show(sel.removeSegment(n, seg("main", 2))),
head: show(sel.removeSegment(n, seg("main", 0))),
only: show(sel.removeSegment(n, seg("branch-1", 0))),
parentOnly: sel.removeSegments(n, [seg("main", 0), seg("main", 1), seg("main", 2)]),
many: show(sel.removeSegments(n, [seg("main", 0), seg("main", 2)])),
box: sel.segmentsInBox(n, view, { x0: 90, y0: -10, x1: 210, y1: 10 }, false),
cross: sel.segmentsInBox(n, view, { x0: 150, y0: 10, x1: 120, y1: -10 }, true),
all: show(sel.removeAll(n)),
nodes: sel.nodesAt(n, [200, 0]),
moved: sel.movePointsAt([[200, 0], [5, 5]], [200, 0], [210, 5]),
placed: (() => {
const out = sel.placeNode(n, [[100, 0], [9, 9]], 0, 1, { kind: "length", length_m: 50, slope_pct: null }, (x) => x * 0.1);
return [out.network.lines[0].vertices, out.network.lines[0].nodes, out.points];
})(),
placeFirst: sel.placeNode(n, [], 0, 0, { kind: "free", length_m: null, slope_pct: null }, () => 0),
nodes594: (() => {
const r = { kind: "length", length_m: 100, slope_pct: null };
const withNodes = { ...n, lines: n.lines.map((l, i) => (i ? l : { ...l, nodes: [null, r, r, r] })) };
const out = sel.removeSegment(withNodes, seg("main", 1));
return out.lines.filter((l) => l.id !== "branch-1").map((l) => [l.id, l.nodes]);
})(),
}));
"""
def _run_ts(tmp_path: Path) -> dict:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--rootDir", str(ROOT),
"--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler",
"--ignoreConfig"],
cwd=str(ROOT), check=True, capture_output=True,
) # fmt: skip
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
result = tmp_path / "out.json"
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def test_토막_자리(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
assert [s["line"] for s in got["segs"]] == ["main"] * 3 + ["branch-1"]
assert got["range"] == [100, 200]
assert got["at"] == [0, 1, 2] # 꺾임점 위 = 뒤 토막 · 끝 = 마지막
assert got["nodeSeg"] == [{"line": "main", "index": 1}, {"line": "main", "index": 2}]
assert got["pieces"] == ["p1", "p2"] # 100~200 토막에 마크 150 = 두 구간
def test_가운데_토막_지움_구역_둘(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
lines = {i: (v, b) for i, v, b in got["middle"]["lines"]}
assert lines["main"] == ([[0, 0], [100, 0]], None) # 앞쪽 = 같은 id
back = next(k for k in lines if k not in ("main", "branch-1"))
assert lines[back] == ([[200, 0], [300, 0]], None) # 뒤쪽 = 새 구역(본선)
assert lines["branch-1"][1] == {"line": back, "vertex": 0} # 갈래 고리가 따라감
pieces = {(p[1], p[2], p[3]): (p[0], p[4]) for p in got["middle"]["pieces"]}
assert pieces[("main", 0, 100)] == ("p1", 2026) # 앞 조각 id 지님
assert pieces[(back, 0, 100)][1] == 2028 # 뒤 조각 해 그대로
def test_끝_토막_지움_끝_점_뺌(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
tail = {i: v for i, v, _ in got["tail"]["lines"]}
assert tail["main"] == [[0, 0], [100, 0], [200, 0]]
head = {i: (v, b) for i, v, b in got["head"]["lines"]}
assert head["main"][0] == [[100, 0], [200, 0], [300, 0]]
assert head["branch-1"][1] == {"line": "main", "vertex": 1} # 갈래 차례 −1
assert [p[2:4] for p in got["head"]["pieces"] if p[1] == "main"] == [[0, 50], [50, 200]]
def test_갈래_붙은_노드_막음_하나뿐_구역_지움(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
only = got["only"]["lines"]
assert [i for i, _, _ in only] == ["main"] # 토막 하나뿐 = 구역 지움
assert got["parentOnly"] == "갈래가 붙은 점이 사라집니다 — 갈래를 먼저 지우세요."
many = {i: v for i, v, _ in got["many"]["lines"]}
assert many["main"] == [[100, 0], [200, 0]] # 뒤부터 지움 = 차례 안 꼬임
def test_끌어_고르기_전체_지점_노드(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
assert got["box"] == [{"line": "main", "index": 1}] # 창 = 두 끝 모두 안
assert got["cross"] == [{"line": "main", "index": 1}] # 걸침 = 상자와 만남
assert got["all"] == {"lines": [], "pieces": []}
assert got["nodes"] == [{"line": "main", "vertex": 2}] # 갈래 첫 점 뺌
assert got["moved"] == [[210, 5], [5, 5]]
vertices, nodes, points = got["placed"]
assert vertices == [[0, 0], [50, 0], [200, 0], [300, 0]] # 앞 노드 기준 50 m · 뒤 노드 그대로
assert nodes[1] == {"kind": "length", "length_m": 50, "slope_pct": None} and nodes[0] is None
assert points == [[50, 0], [9, 9]] # 그 자리 지점 같이
assert got["placeFirst"] == "첫 노드는 방식이 없습니다."
rec = {"kind": "length", "length_m": 100, "slope_pct": None}
assert got["nodes594"] == [
["main", [None, rec]],
["line-2", [None, rec]],
] # 노드 기록 같이 · 뒤쪽 첫 = 없음
+138
View File
@@ -0,0 +1,138 @@
"""D03 거리재기 「노선 따라」 셈(PLAN 600-2 · 요구 463-23) — `logic/D03_PlanAnalysis_Measure.ts`.
같은 선 = 누가 차 · 갈래 ↔ 본선 = 갈래 고리를 따라 올라가 만나는 점까지 · 이어지지 않은 본선끼리 = null.
TS 를 tsc 로 옮겨 node 로 돌림(서버 · 화면 없이).
./venv/Scripts/python.exe -m pytest resources/tester/test_600_2_plan_measure.py -q
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Measure.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
# main 200 m (0,0)→(100,0)→(100,100) · b1 = main 꼭짓점 1 에서 60 m · b2 = b1 끝에서 40 m · other = 이어지지 않은 본선
NETWORK = {
"pieces": [],
"lines": [
{"id": "main", "vertices": [[0, 0], [100, 0], [100, 100]], "branch": None},
{"id": "b1", "vertices": [[100, 0], [160, 0]], "branch": {"line": "main", "vertex": 1}},
{"id": "b2", "vertices": [[160, 0], [160, 40]], "branch": {"line": "b1", "vertex": 1}},
{"id": "other", "vertices": [[0, 500], [50, 500]], "branch": None},
],
}
CASES = {
"same": ["routeSpan", NETWORK, {"line": "main", "atM": 40}, {"line": "main", "atM": 150}],
"same_back": ["routeSpan", NETWORK, {"line": "main", "atM": 150}, {"line": "main", "atM": 40}],
"branch_main_after": [
"routeSpan",
NETWORK,
{"line": "b1", "atM": 30},
{"line": "main", "atM": 150},
],
"branch_main_before": [
"routeSpan",
NETWORK,
{"line": "b1", "atM": 30},
{"line": "main", "atM": 60},
],
"twig_root": ["routeSpan", NETWORK, {"line": "b2", "atM": 40}, {"line": "main", "atM": 0}],
"twig_twig": ["routeSpan", NETWORK, {"line": "b2", "atM": 10}, {"line": "b1", "atM": 20}],
"apart": ["routeSpan", NETWORK, {"line": "main", "atM": 10}, {"line": "other", "atM": 10}],
"near_branch": ["nearestOnRoute", NETWORK, [130, 5], {"line": "main", "atM": 10}],
"near_other_excluded": ["nearestOnRoute", NETWORK, [20, 490], {"line": "main", "atM": 10}],
}
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import * as measure from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Measure.js";
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
const out = {};
for (const [key, [name, ...args]] of Object.entries(cases)) out[key] = measure[name](...args);
writeFileSync(process.argv[3], JSON.stringify(out));
"""
@pytest.fixture(scope="module")
def r(tmp_path_factory: pytest.TempPathFactory) -> dict:
tmp = tmp_path_factory.mktemp("measure")
out = tmp / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(TS_FILE),
"--outDir",
str(out),
"--rootDir",
str(ROOT),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
job, result = tmp / "job.json", tmp / "out.json"
job.write_text(json.dumps(CASES), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(job), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def test_같은_선은_누가_차(r):
assert r["same"]["lengthM"] == pytest.approx(110)
assert r["same"]["stations"] == [40, 150]
assert r["same"]["path"][0] == [40, 0]
assert r["same"]["path"][-1] == [100, 50]
assert r["same_back"]["lengthM"] == pytest.approx(110)
assert r["same_back"]["path"][0] == [100, 50]
def test_갈래에서_본선으로(r):
"""갈래 30 m 지점 → 갈래 첫 점(30) → 본선 100 m → 150 m(50) = 80 · 본선 60 m 쪽 = 30 + 40 = 70."""
assert r["branch_main_after"]["lengthM"] == pytest.approx(80)
assert r["branch_main_after"]["stations"] is None
assert r["branch_main_after"]["path"][0] == [130, 0]
assert r["branch_main_before"]["lengthM"] == pytest.approx(70)
def test_갈래의_갈래는_고리를_따라_올라감(r):
"""b2 끝(40) → b1 끝(160,0)까지 → b1 첫 점(60) → 본선 0 m(100) = 200 m."""
assert r["twig_root"]["lengthM"] == pytest.approx(200)
# b2 10 m(160,10) → b2 첫 점 10 → b1 20 m(120,0) 까지 40 = 50
assert r["twig_twig"]["lengthM"] == pytest.approx(50)
def test_이어지지_않은_본선은_길_없음(r):
assert r["apart"] is None
def test_가까운_자리는_이어진_선_안에서만(r):
assert r["near_branch"]["line"] == "b1"
assert r["near_branch"]["atM"] == pytest.approx(30)
assert r["near_other_excluded"]["line"] == "main"
@@ -0,0 +1,172 @@
"""D03 급경사 범위(PLAN 603 · 요구 466 · 467 · 468) — 서버 1 % 칸 수 +
`logic/D03_PlanAnalysis_SlopeBands.ts`.
1 % 칸 수 합 = 유효 칸 · 기본 범위(26.8 · 70.0 · 83.9 %) 비율 = 옛 15 · 35 · 40° 비율 ·
범위 막음(빈 값 · 0~250 밖 · 아래 ≥ 위 · 겹침 · 없음) · 깨진 취향 = 기본 · 250 % 이상 칸 = 끝 범위.
TS 를 tsc 로 옮겨 node 로 돌림(서버 · 화면 없이).
./venv/Scripts/python.exe -m pytest resources/tester/test_603_plan_slope_bands.py -q
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import numpy as np
import pytest
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
from E07_StabArea.E07_StabArea_Engine import slope_rad
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_SlopeBands.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
CELL = 2.0
OLD_DEG = ((15.0, 35.0), (35.0, 40.0), (40.0, 90.0)) # 옛 SLOPE_BANDS
def _hills(n: int = 300) -> np.ndarray:
"""0 ~ 60° 남짓 고루 섞인 언덕(구멍 하나)."""
y, x = np.mgrid[0:n, 0:n] * CELL
return 40 * np.sin(x / 37) * np.cos(y / 53) + 25 * np.sin((x + 2 * y) / 19) + 0.3 * x
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import * as bands from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.js";
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
const out = { DEFAULT: bands.DEFAULT_SLOPE_BANDS };
// 인자 null = 기본 범위
for (const [key, [name, ...args]] of Object.entries(cases))
out[key] = bands[name](...args.map((a) => (a === null ? bands.DEFAULT_SLOPE_BANDS : a)));
writeFileSync(process.argv[3], JSON.stringify(out));
"""
C = "--draw-map-landslide-3"
@pytest.fixture(scope="module")
def hills() -> dict:
z = _hills()
valid = np.ones(z.shape, bool)
valid[100:140, 100:180] = False
value = layers.slope_values_png(z, valid, CELL)
deg = np.degrees(slope_rad(np.where(valid, z, np.nan), CELL))
known = np.isfinite(deg)
old = [float(((deg >= lo) & (deg < hi) & known).sum() / known.sum()) for lo, hi in OLD_DEG]
return {"counts": value["counts"], "known": int(known.sum()), "old": old}
@pytest.fixture(scope="module")
def r(tmp_path_factory: pytest.TempPathFactory, hills: dict) -> dict:
tmp = tmp_path_factory.mktemp("slope_bands")
out = tmp / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(TS_FILE),
"--outDir",
str(out),
"--rootDir",
str(ROOT),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
band = lambda lo, hi: {"low": lo, "high": hi, "color": C} # noqa: E731
top = [0] * 250 + [7]
cases = {
"default_ratios": ["bandRatios", hills["counts"], None],
"user_ratios": [
"bandRatios",
hills["counts"],
[band(10, 15), band(15, 20), band(20, 250), band(0, 10)],
],
"top": ["bandRatios", top, [band(83.9, 250)]],
"top_closed": ["bandRatios", top, [band(83.9, 249)]],
"ok": ["checkBands", [band(10, 15), band(15, 20), band(20, 250), band(0, 10)]],
"overlap": ["checkBands", [band(10, 20), band(15, 30)]],
"empty_value": ["checkBands", [band(None, 20)]],
"out_of_range": ["checkBands", [band(10, 260)]],
"upside_down": ["checkBands", [band(30, 20)]],
"none": ["checkBands", []],
"bad_color": ["checkBands", [{"low": 1, "high": 2, "color": "red"}]],
"parse_broken": ["parseBands", "{oops"],
"parse_overlap": ["parseBands", json.dumps([band(10, 20), band(15, 30)])],
"parse_ok": ["parseBands", json.dumps([band(10, 20)])],
"deg": ["pctToDeg", 100],
}
job, result = tmp / "job.json", tmp / "out.json"
job.write_text(json.dumps(cases), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(job), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def test_칸_수_합은_유효_칸(hills):
assert sum(hills["counts"]) == hills["known"]
assert len(hills["counts"]) == layers.SLOPE_MAX_PCT + 1
def test_기본은_지식DB_15_35_40도(r):
assert [(b["low"], b["high"]) for b in r["DEFAULT"]] == [
(26.8, 70.0),
(70.0, 83.9),
(83.9, 250),
]
def test_기본_비율은_옛_15_35_40도_비율과_같음(r, hills):
"""1 % 칸 경계(26.8 → 27 · 83.9 → 84 %) 차이만 — 범례 0.1 % 자리 반 칸 안."""
for new, old in zip(r["default_ratios"], hills["old"], strict=True):
assert old > 0.02
assert new == pytest.approx(old, abs=0.002)
def test_사용자_범위_넷(r):
ratios = r["user_ratios"]
assert len(ratios) == 4 and sum(ratios) == pytest.approx(1.0)
def test_250_퍼센트_이상은_끝_범위(r):
assert r["top"] == [1.0]
assert r["top_closed"] == [0.0]
def test_범위_막음(r):
assert r["ok"] is None
assert "겹칩니다" in r["overlap"]
assert "비었습니다" in r["empty_value"]
assert "0~250" in r["out_of_range"]
assert "아래가 위보다" in r["upside_down"]
assert "하나는" in r["none"]
assert "색" in r["bad_color"]
def test_깨진_취향은_기본(r):
assert r["parse_broken"] == r["DEFAULT"]
assert r["parse_overlap"] == r["DEFAULT"]
assert r["parse_ok"] == [{"low": 10, "high": 20, "color": C}]
assert r["deg"] == pytest.approx(45.0)
+13 -2
View File
@@ -65,6 +65,17 @@ export function satelliteTileSource(projectId: string): SourceSpecification {
};
}
/** 느린 조작 잼(600-4) — `globalThis.__aisloPerf = true` 일 때만 `performance.measure(이름)` 으로 걸린 ms 를 남김(꺼져 있으면 그냥 실행). */
export function perfTime<T>(name: string, run: () => T): T {
if (!(globalThis as { __aisloPerf?: boolean }).__aisloPerf) return run();
const start = performance.now();
try {
return run();
} finally {
performance.measure(name, { start, end: performance.now() });
}
}
export function createGlMap<K extends string>(opts: {
/** 겹 차례(아래 → 위)와 꼴 — 밝음 · 어두움이 바뀌면 다시 부름. */
specs: () => GlSpec<K>[];
@@ -190,8 +201,8 @@ export function createGlMap<K extends string>(opts: {
camera(center, zoom, bearingDeg) {
if (!map) return;
if (ready && themeKey() !== theme) apply();
map.jumpTo({ center, zoom, bearing: bearingDeg });
if (ready) map.redraw(); // 캔버스와 같은 화면(다음 화면으로 미루지 않음)
perfTime("gl:jumpTo", () => map!.jumpTo({ center, zoom, bearing: bearingDeg }));
if (ready) perfTime("gl:redraw", () => map!.redraw()); // 캔버스와 같은 화면(다음 화면으로 미루지 않음)
},
resize: () => map?.resize(),
};
+7
View File
@@ -102,6 +102,13 @@ export const ui_locales_de = {
DE_Reg_SiteNoAddress: ["주소를 찾지 못했습니다", "Address not found"],
DE_Reg_SiteSource: ["출처", "Source"],
DE_Reg_SiteRequired: ["지도에서 기준점을 찍으세요.", "Set the reference point on the map."],
DE_Reg_Find: ["주소 · 장소 찾기 (Enter)", "Find address or place (Enter)"],
DE_Reg_FindShort: ["두 글자 이상", "Two or more characters"],
DE_Reg_FindBusy: ["찾는 중…", "Searching…"],
DE_Reg_FindNone: ["찾은 곳 없음", "Nothing found"],
DE_Reg_FindFail: ["찾지 못함", "Search failed"],
DE_Reg_FindAddress: ["주소", "Address"],
DE_Reg_FindPlace: ["장소", "Place"],
DE_Reg_MapType: ["바탕 지도", "Base map"],
DE_Reg_MapNormal: ["일반", "Map"],
DE_Reg_MapSatellite: ["위성", "Satellite"],