Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-10-29 18:43:31 +09:00
22 changed files with 450 additions and 243 deletions
+28 -31
View File
@@ -1,13 +1,12 @@
"""D 예정노선 1단계 기준 지정 — 전체 시점 · 종점 목록 정본 API
(요구서 389 3장 1 · 440 1 · PLAN 560-2 · 579-1).
"""D 예정노선 1단계 기준 지정 — 지점 목록 하나 정본 API
(요구서 389 3장 1 · 440 1 · 445 A2 · PLAN 560-2 · 579-1 · 585-1).
- `GET /api/projects/{id}/plan/points` — {epsg, starts: [점], ends: [점]} · 점 = {lon, lat, x, y}
- `PUT …/plan/points` — {starts: [{lon, lat}], ends: [...]} → TM 셈 → `projects.plan_base_json`
(`{"epsg", "starts": [{"x", "y"}], "ends": [...]}` · 좌표계 = 저장된 값 ·
없으면 점 평균 경도의 TM 띠)
- 시점 · 종점은 짝 없는 따로 목록 · 옛 꼴(`start` · `end` 한 점씩)은 읽을 때 목록으로 감쌈
(옮김 없음).
- 칸이 비면(옛 프로젝트) 첫 연도 시점 + 마지막 연도 종점으로 접어 보임 · 연도 행은 D03 [저장] 몫.
- `GET /api/projects/{id}/plan/points` — {epsg, points: [점]} · 점 = {lon, lat, x, y}
- `PUT …/plan/points` — {points: [{lon, lat}]} → TM 셈 → `projects.plan_base_json`
(`{"epsg", "points": [{"x", "y"}]}` · 좌표계 = 저장된 값 · 없으면 점 평균 경도의 TM 띠)
- 시점 · 종점 구분 없음 — 옛 꼴(`starts` + `ends` 목록 · 더 옛 `start` · `end` 한 점)은 읽을 때
이어 붙임(옮김 없음 · 쓰기는 새 꼴).
- 칸이 비면(옛 프로젝트) [첫 연도 시점, 마지막 연도 종점]으로 접어 보임 · 연도 행은 D03 [저장] 몫.
- 고침 = 산림청만(지정 회사 GET 은 공용 판정 `require_project_access` 몫).
"""
@@ -27,7 +26,7 @@ from config.config_db import get_db_pool
router = APIRouter(prefix="/api/projects", tags=["D02 Plan Base"])
MAX_POINTS = 20 # 시점 · 종점 목록마다 상한
MAX_POINTS = 40 # 지점 상한(옛 시점 20 + 종점 20)
class LonLat(BaseModel):
@@ -36,8 +35,7 @@ class LonLat(BaseModel):
class BaseRequest(BaseModel):
starts: list[LonLat] = Field(default_factory=list, max_length=MAX_POINTS)
ends: list[LonLat] = Field(default_factory=list, max_length=MAX_POINTS)
points: list[LonLat] = Field(default_factory=list, max_length=MAX_POINTS)
async def _load(project_id: UUID) -> tuple[dict[str, Any], dict[int, dict[str, Any]]]:
@@ -78,45 +76,44 @@ def _stored(project: dict[str, Any]) -> dict[str, Any] | None:
def base_view(project: dict[str, Any], rows: dict[int, dict[str, Any]]) -> dict[str, Any]:
"""{epsg, starts, ends} — 정본 칸(옛 한 점 꼴은 감쌈) ·
비면 첫 연도 시점 + 마지막 연도 종점으로 접음."""
"""{epsg, points} — 정본 칸(옛 시점 · 종점 꼴은 이어 붙임) ·
비면 [첫 연도 시점, 마지막 연도 종점]으로 접음."""
stored = _stored(project)
if stored:
epsg = stored.get("epsg")
starts = stored.get("starts", [stored["start"]] if stored.get("start") else [])
ends = stored.get("ends", [stored["end"]] if stored.get("end") else [])
items = stored.get("points")
if items is None:
starts = stored.get("starts", [stored["start"]] if stored.get("start") else [])
ends = stored.get("ends", [stored["end"]] if stored.get("end") else [])
items = [*starts, *ends]
elif rows:
first, last = rows[min(rows)], rows[max(rows)]
epsg = first.get("epsg")
starts = [{"x": first.get("start_x"), "y": first.get("start_y")}]
ends = [{"x": last.get("end_x"), "y": last.get("end_y")}]
items = [
{"x": first.get("start_x"), "y": first.get("start_y")},
{"x": last.get("end_x"), "y": last.get("end_y")},
]
else:
epsg, starts, ends = None, [], []
def points(items: list[dict[str, Any]]) -> list[dict[str, float]]:
return [p for p in (_point(i.get("x"), i.get("y"), epsg) for i in items) if p]
return {"epsg": epsg, "starts": points(starts), "ends": points(ends)}
epsg, items = None, []
points = [p for p in (_point(i.get("x"), i.get("y"), epsg) for i in items) if p]
return {"epsg": epsg, "points": points}
async def load_base(project_id: UUID) -> dict[str, Any]:
"""전체 시점 · 종점 목록 — D03 이 읽는 길."""
"""지점 목록 — D03 이 읽는 길."""
project, rows = await _load(project_id)
return base_view(project, rows)
def stored_base(payload: BaseRequest, epsg: int | None = None) -> dict[str, Any]:
"""PUT 몸 → 정본 칸 꼴(TM m) — 좌표계 = `epsg`(저장된 값) · 없으면 점 평균 경도의 TM 띠."""
given = [*payload.starts, *payload.ends]
given = payload.points
if not given:
epsg = None
elif epsg is None:
epsg = tm_epsg_for_lon(sum(p.lon for p in given) / len(given))
def tm(items: list[LonLat]) -> list[dict[str, float]]:
return [dict(zip(("x", "y"), lonlat_to_tm(p.lon, p.lat, epsg))) for p in items]
return {"epsg": epsg, "starts": tm(payload.starts), "ends": tm(payload.ends)}
points = [dict(zip(("x", "y"), lonlat_to_tm(p.lon, p.lat, epsg))) for p in given]
return {"epsg": epsg, "points": points}
async def _store_base(project_id: UUID, base: dict[str, Any]) -> None:
+61 -79
View File
@@ -1,7 +1,8 @@
/* =============================================================================
* D02_PlanBase_UI_Page.ts
* D02 1단계 기준 지정(요구서 345 3-2 · 389 3장 1 · 440 1 · PLAN 560-2 · 579-2) — 메인 = 카카오맵 ·
* 좌 = 시점 목록 · 종점 목록(짝 없이 따로 · 최소 한 줄씩 · 줄마다 🗑 · [+ 시점] [+ 종점]) · 점 잇는 선 없음.
* D02 1단계 기준 지정(요구서 345 3-2 · 389 3장 1 · 440 1 · 445 A2 · PLAN 560-2 · 579-2 · 585-1) — 메인 = 카카오맵 ·
* 좌 = 지점 목록 하나(최소 한 줄 · 줄마다 🗑 · [+ 지점] · 지점마다 차례 색 `pointColor` = 노선분석 핀과 같음) ·
* 점 잇는 선 없음.
* 조작(끌기 · 지도 누름 · 경위도 입력) = 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈)
* · 떠남(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) = 바뀐 것 있으면 공용 물음 [취소][폐기][저장](572-3)
* · 자동저장 없음(CLAUDE.md 5장) · 캐시는 그때 서버 값(base)이 지금과 같을 때만 씀(D03 이 고친 값 우선).
@@ -25,6 +26,7 @@ import {
import { createPanelCard } from "@ui/ui_template_collapsible";
import { createText } from "@ui/ui_template_text";
import { editMap, type MapPin, type MapType } from "@ui/ui_template_kakao_map";
import { pointColor } from "@ui/ui_template_palette";
import { createSegment } from "@ui/ui_template_segment";
import { t } from "@ui/ui_template_locale";
import {
@@ -48,11 +50,9 @@ interface ServerPoint extends LonLat {
}
interface PointsView {
epsg: number | null;
starts: ServerPoint[];
ends: ServerPoint[];
points: ServerPoint[];
}
type Side = "starts" | "ends";
type Points = Record<Side, Array<LonLat | null>>;
type Points = Array<LonLat | null>;
const KOREA_CENTER: [number, number] = [127.8, 36.3];
const DIGITS = 7; // 경위도 소수 7 자리 ≈ 1 cm
@@ -70,15 +70,12 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return data as T;
}
const SIDES: Side[] = ["starts", "ends"];
/** 서버 목록 → 표 줄(빈 목록 = 빈 줄 하나) */
function boundaryPoints(view: PointsView | null): Points {
const pick = (list: ServerPoint[] = []): Array<LonLat | null> =>
list.length
? list.map((p) => ({ lon: +p.lon.toFixed(DIGITS), lat: +p.lat.toFixed(DIGITS) }))
: [null];
return { starts: pick(view?.starts), ends: pick(view?.ends) };
const list = view?.points ?? [];
return list.length
? list.map((p) => ({ lon: +p.lon.toFixed(DIGITS), lat: +p.lat.toFixed(DIGITS) }))
: [null];
}
export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
@@ -108,22 +105,13 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
let view: PointsView | null = null;
let points: Points = boundaryPoints(null);
let active: string | null = null; // 지도 누름이 놓을 지점(`starts:0` 꼴)
let active: number | null = null; // 지도 누름이 놓을 지점 차례
let editable = false; // 산림청만 고침
const keyOf = (side: Side, i: number): string => `${side}:${i}`;
const fromKey = (key: string): [Side, number] => {
const [side, i] = key.split(":");
return [side as Side, Number(i)];
};
const pointName = (side: Side, i: number): string =>
`${t(side === "starts" ? "D02_Start" : "D02_End")} ${i + 1}`;
const firstEmpty = (): string | null => {
for (const side of SIDES) {
const i = points[side].findIndex((p) => !p);
if (i >= 0) return keyOf(side, i);
}
return null;
const pointName = (i: number): string => `${t("D02_Point")} ${i + 1}`;
const firstEmpty = (): number | null => {
const i = points.findIndex((p) => !p);
return i >= 0 ? i : null;
};
const serverPoints = (): Points => boundaryPoints(view);
const dirty = (): boolean => JSON.stringify(points) !== JSON.stringify(serverPoints());
@@ -133,7 +121,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
const map = editMap({
hint: t("D02_Map_Hint"),
center: () => KOREA_CENTER,
onMove: (key, lon, lat) => place(key, { lon, lat }),
onMove: (key, lon, lat) => place(Number(key), { lon, lat }),
onClick: (lon, lat) => {
if (!editable) return;
if (active === null) return showToast(t("D02_Pick_Point"), "info");
@@ -157,26 +145,25 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
typePick.root.classList.add("d02-map__type");
mapHost.append(map.root, typePick.root);
function place(key: string, at: LonLat): void {
const [side, i] = fromKey(key);
points[side][i] = { lon: +at.lon.toFixed(DIGITS), lat: +at.lat.toFixed(DIGITS) };
if (key === active) active = firstEmpty(); // 놓은 지점 = 다음 빈 지점으로
function place(i: number, at: LonLat): void {
points[i] = { lon: +at.lon.toFixed(DIGITS), lat: +at.lat.toFixed(DIGITS) };
if (i === active) active = firstEmpty(); // 놓은 지점 = 다음 빈 지점으로
remember();
paint(key);
paint(String(i));
}
/** [+ 시점] · [+ 종점] — 빈 줄을 더하고 지도 누름이 거기 놓음. */
function addRow(side: Side): void {
points[side].push(null);
active = keyOf(side, points[side].length - 1);
/** [+ 지점] — 빈 줄을 더하고 지도 누름이 거기 놓음. */
function addRow(): void {
points.push(null);
active = points.length - 1;
remember();
paint();
}
/** 🗑 — 줄을 지움(마지막 한 줄이면 비움 · 최소 한 줄). */
function removeRow(side: Side, i: number): void {
if (points[side].length > 1) points[side].splice(i, 1);
else points[side][0] = null;
/** 🗑 — 줄을 지움(마지막 한 줄이면 비움 · 최소 한 줄 · 뒤 번호 · 색이 한 칸씩 당겨짐). */
function removeRow(i: number): void {
if (points.length > 1) points.splice(i, 1);
else points[0] = null;
active = firstEmpty();
remember();
paint();
@@ -184,25 +171,24 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
/** focus = 고친 지점(보기 밖이면 그리로) · 빈 값 = 보기 그대로 · 점 잇는 선 없음(요구 440 1). */
function drawMap(focus = ""): void {
const pins: MapPin[] = SIDES.flatMap((side) =>
points[side].flatMap((p, i) =>
p
? [
{
key: keyOf(side, i),
name: pointName(side, i),
lon: p.lon,
lat: p.lat,
movable: editable,
},
]
: [],
),
const pins: MapPin[] = points.flatMap((p, i) =>
p
? [
{
key: String(i),
name: pointName(i),
lon: p.lon,
lat: p.lat,
movable: editable,
color: pointColor(i),
},
]
: [],
);
map.set(pins, [], undefined, focus);
}
function pointRow(side: Side, p: LonLat | null, i: number): HTMLElement {
function pointRow(p: LonLat | null, i: number): HTMLElement {
const lon = createInputField({ type: "number", placeholder: t("D02_Lon"), size: 24 }).input;
const lat = createInputField({ type: "number", placeholder: t("D02_Lat"), size: 24 }).input;
lon.classList.add("d02-points__input");
@@ -211,7 +197,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
lon.value = p ? String(p.lon) : "";
lat.value = p ? String(p.lat) : "";
lon.disabled = lat.disabled = !editable;
lon.dataset.point = lat.dataset.point = keyOf(side, i);
lon.dataset.point = lat.dataset.point = String(i);
lon.dataset.axis = "lon";
lat.dataset.axis = "lat";
const typed = (): void => {
@@ -222,7 +208,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
if (ok) box.removeAttribute("aria-invalid");
else box.setAttribute("aria-invalid", "true");
}
if (ok) place(keyOf(side, i), at);
if (ok) place(i, at);
};
lon.addEventListener("change", typed);
lat.addEventListener("change", typed);
@@ -231,7 +217,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
variant: "ghost",
size: "sm",
onClick: () => {
active = keyOf(side, i);
active = i;
drawTable();
},
});
@@ -241,39 +227,35 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
icon: "trash",
title: t("D02_Remove_Point"),
size: 24,
onClick: () => removeRow(side, i),
onClick: () => removeRow(i),
});
drop.hidden = !editable;
const row = el("div", {
className: `d02-points__row${active === keyOf(side, i) ? " is-active" : ""}`,
className: `d02-points__row${active === i ? " is-active" : ""}`,
children: [
el("span", { className: "d02-points__name", text: pointName(side, i) }),
el("span", { className: "d02-points__name", text: pointName(i) }),
pick,
lon,
lat,
drop,
],
});
row.style.borderLeftColor = pointColor(i);
return row;
}
function drawTable(): void {
const rows = SIDES.flatMap((side) => {
const add = createButton({
label: t(side === "starts" ? "D02_Add_Start" : "D02_Add_End"),
iconStart: "plus",
variant: "ghost",
size: 24,
onClick: () => addRow(side),
});
add.hidden = !editable;
return [...points[side].map((p, i) => pointRow(side, p, i)), el("div", { children: [add] })];
const add = createButton({
label: t("D02_Add_Point"),
iconStart: "plus",
variant: "ghost",
size: 24,
onClick: () => addRow(),
});
table.replaceChildren(...rows);
const count = (side: Side): number => view?.[side].length ?? 0;
add.hidden = !editable;
table.replaceChildren(...points.map((p, i) => pointRow(p, i)), el("div", { children: [add] }));
info.textContent = view?.epsg
? `${t("D02_Start")} ${count("starts")} · ${t("D02_End")} ${count("ends")} · ` +
`${t("D02_Saved_Crs")} EPSG:${view.epsg}`
? `${t("D02_Point")} ${view.points.length} · ${t("D02_Saved_Crs")} EPSG:${view.epsg}`
: t("D02_Not_Saved");
}
@@ -290,7 +272,7 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
async function store(): Promise<boolean> {
showLoadingOverlay();
try {
const body = { starts: points.starts.filter(Boolean), ends: points.ends.filter(Boolean) };
const body = { points: points.filter(Boolean) };
adopt(
await request<PointsView>(`/projects/${projectId}/plan/points`, {
method: "PUT",
@@ -321,8 +303,8 @@ export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
adopt(await request<PointsView>(`/projects/${projectId}/plan/points`));
const cached = readState<{ base?: Points; points?: Points }>("d02-points", projectId);
const sameBase = JSON.stringify(cached?.base) === JSON.stringify(points);
// 옛 캐시(두 칸 배열)는 꼴이 달라 버림
if (editable && sameBase && cached?.points?.starts?.length && cached.points.ends?.length)
// 옛 캐시(시점 · 종점 두 목록)는 꼴이 달라 버림
if (editable && sameBase && Array.isArray(cached?.points) && cached.points.length)
points = cached.points;
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
+1 -1
View File
@@ -1,4 +1,4 @@
/* D02 기준 지정(529-3 · 560-2 · 579-2) — 메인 지도 = 본문 높이 가득 · 좌 시점 목록 · 종점 목록 */
/* D02 기준 지정(529-3 · 560-2 · 579-2 · 585-1) — 메인 지도 = 본문 높이 가득 · 좌 지점 목록(줄 왼 띠 = 지점 색) */
.d02-map {
position: relative;
height: calc(100dvh - var(--app-header-h));
+2 -3
View File
@@ -19,11 +19,10 @@ export interface BasePoint {
y: number;
}
/** D02 정본 — 시점 목록 · 종점 목록(작업 좌표계 m + 경위도 · 579-1). */
/** D02 정본 — 지점 목록 하나(작업 좌표계 m + 경위도 · 585-1). */
export interface PlanBaseView {
epsg: number | null;
starts: BasePoint[];
ends: BasePoint[];
points: BasePoint[];
}
export interface PlanJob {
+4 -7
View File
@@ -10,7 +10,7 @@
길이 · 측점 · 높이는 저장하지 않음(늘 다시 셈 · 높이는 지형에서). 측점 = 선마다 No.0 부터.
조각 = 선의 마크 사이 구간(구역 × 갈래) · 업체 지정은 조각 단위(`plan_route_assignees.piece_id`).
전체 시점 · 종점(`base`) = D02 정본(`plan_base.load_base`) — 비어 옛 연도 행을 접어 보이던 것은
지점 목록(`base` · 585-1) = D02 정본(`plan_base.load_base`) — 비어 옛 연도 행을 접어 보이던 것은
[저장] 전에 칸에 굳힘(연도 행이 바뀌어도 그대로).
계산 자리 = ① 파이썬·TS 짝(`D03_PlanAnalysis_Draw`) — 화면은 그리는 즉시 · [저장]은 여기서 다시 셈.
"""
@@ -388,11 +388,8 @@ async def _store(
async def _freeze_base(connection: Any, project_id: UUID, base: dict[str, Any]) -> None:
"""옛 연도 행을 접은 시점 · 종점 → D02 정본 칸(비었을 때만)."""
stored = {
"epsg": base["epsg"],
**{key: [{"x": p["x"], "y": p["y"]} for p in base[key]] for key in ("starts", "ends")},
}
"""옛 연도 행을 접은 지점 → D02 정본 칸(비었을 때만)."""
stored = {"epsg": base["epsg"], "points": [{"x": p["x"], "y": p["y"]} for p in base["points"]]}
async with connection.cursor() as cursor:
await cursor.execute(
"UPDATE projects SET plan_base_json = %s WHERE id = %s AND plan_base_json IS NULL",
@@ -426,7 +423,7 @@ async def put_plan_network(
raise HTTPException(status_code=400, detail=str(error)) from error
await connection.begin()
try:
if project["plan_base_json"] is None and base["starts"] and base["ends"]:
if project["plan_base_json"] is None and base["points"]:
await _freeze_base(connection, project_id, base)
await _store(connection, project_id, network, derived)
await connection.commit()
+6 -8
View File
@@ -8,7 +8,7 @@
`…/forest-roads`(GET · POST 올리기) · `…/landslide-zones` — 지도 겹
(PLAN 569-1 · 581-1 · 581-2 · 584-1 · 584-3)
범위 = 전체 시점 · 종점(D02 정본 `load_base`) + 그은 점(`extra`) 상자 + 여유.
범위 = 지점(D02 정본 `load_base`) + 그은 점(`extra`) 상자 + 여유.
노선 정본(손 그은 선)은
`D03_PlanAnalysis_Network`(PLAN 562-2 · 옛 추천 엔진 · 꺾임점 R 걷음).
"""
@@ -54,17 +54,15 @@ class TerrainBody(BaseModel):
async def _plan_ends(project_id: UUID) -> tuple[Path, int, list[tuple[float, float]]]:
"""(실경로, 좌표계, [전체 시점들 · 종점들]) — D02 정본(빈 칸이면 옛 연도 행 접음)."""
"""(실경로, 좌표계, [지점들]) — D02 정본(빈 칸이면 옛 연도 행 접음)."""
plan = await base.load_base(project_id)
if not plan["starts"] or not plan["ends"]:
raise HTTPException(
status_code=400, detail="전체 시점 · 종점이 없습니다 — 1단계에서 지정하세요."
)
if not plan["points"]:
raise HTTPException(status_code=400, detail="지점이 없습니다 — 1단계에서 지정하세요.")
async with get_db_pool().acquire() as connection:
stored = await get_project_storage_relative_path(connection, project_id)
if not stored:
raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.")
ends = [(float(p["x"]), float(p["y"])) for p in (*plan["starts"], *plan["ends"])]
ends = [(float(p["x"]), float(p["y"])) for p in plan["points"]]
return Path(resolve_stored_project_path(stored)), int(plan["epsg"]), ends
@@ -131,7 +129,7 @@ async def _save_models(project_id: UUID, models: list[dict[str, Any]], epsg: int
async def plan_terrain_extent(
project_id: UUID, margin_m: float | None = None, extra: str | None = None
) -> dict[str, Any]:
"""받기 전 범위 — 전체 시점 · 종점(+ `extra` 그은 점 `[[x, y]]`) 상자 + 여유 ∪ 받은 범위."""
"""받기 전 범위 — 지점(+ `extra` 그은 점 `[[x, y]]`) 상자 + 여유 ∪ 받은 범위."""
root, epsg, ends = await _plan_ends(project_id)
view = await asyncio.to_thread(
terrain.plan_extent, root, [*ends, *_query_xy(extra)], epsg, margin_m
+28 -30
View File
@@ -2,9 +2,9 @@
* D03_PlanAnalysis_UI_Draw.ts
* D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 580 · 요구서 389 3장 8~10 · 440 3~7) — 점을 찍어 직선으로 이음.
*
* [긋기] 하나 — 첫 누름 자리로 시작: 시점 = 새 본선 · 종점 = 새 본선(끝낼 때 뒤집어 종점이 선 끝 ·
* 측점 No.0 = 반대쪽) · 선 꼬리 = 이어 긋기 · 갈래 없는 선 머리 = 뒤집어 이어 긋고 끝낼 때 되돌림 ·
* 선 위 · 꺾임점 = 갈래. 시점 · 종점 · 다른 선 둘레(SNAP_PX)에 오면 거기 붙고 그 점에서 끝 —
* [긋기] 하나 — 첫 누름 자리로 시작: 지점 = 새 본선(측점 No.0 = 긋기 시작한 지점 · 반대는 [방향 바꾸기]) ·
* 선이 이미 놓인 지점 = 그 점의 갈래 · 선 꼬리 = 이어 긋기 · 갈래 없는 선 머리 = 뒤집어 이어 긋고 끝낼 때
* 되돌림 · 선 위 · 꺾임점 = 갈래. 다른 지점 · 다른 선 둘레(SNAP_PX)에 오면 거기 붙고 그 점에서 끝 —
* 머리가 다른 선 꼬리에 닿으면 한 선으로 잇고 · 꺾임점에 닿으면 그 점의 갈래.
* 미리보기 = 마지막 점 → 마우스 길이(m) · 가상 종단 기울기(원지반 % · ° · + 오르막) — 제약 칸 넷:
* 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중
@@ -78,7 +78,8 @@ export interface DrawPreview {
}
export interface PlanDrawParams {
base: () => { starts: Vertex[]; ends: Vertex[] };
/** 기준 지점(D02 지점 목록 · 585-1). */
base: () => Vertex[];
network: () => PlanNetwork;
heightAt: () => (x: number, y: number) => number;
commit: (next: PlanNetwork) => void;
@@ -213,11 +214,11 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
? "보기만 — 산림청 계정에서 긋습니다."
: mode === "draw"
? active === null
? "시점 · 종점 · 선 끝 · 선 위를 눌러 시작합니다."
? "지점 · 선 끝 · 선 위를 눌러 시작합니다."
: "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기"
: mode === "mark"
? "선 위를 눌러 연도를 나눕니다 · 오른쪽 누름 = 마크 지움"
: "[긋기]로 시점 · 종점 · 선에서 긋습니다 · 선을 누르면 고릅니다.";
: "[긋기]로 지점 · 선에서 긋습니다 · 선을 누르면 고릅니다.";
}
fieldsState();
@@ -227,18 +228,18 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
return vertices[vertices.length - 1] ?? null;
};
/** 붙을 자리 — 앞에서 = 종점 · 끝에서 = 시점 · 다른 선 꺾임점 · 다른 선 위(그리는 선 빼고). */
/** 붙을 자리 — 그리는 선 첫 점 아닌 지점 · 다른 선 꺾임점 · 다른 선 위(그리는 선 빼고). */
function snapAt(
px: number,
py: number,
map: ScreenMap,
): { point: Vertex; note: string; onLine: DrawPreview["onLine"] } | null {
const { starts, ends } = params.base();
const network = params.network();
const offers = (flip ? starts : ends).map((point) => ({
point,
note: flip ? "시점에 붙음" : "종점에 붙음",
}));
const head = active === null ? undefined : network.lines[active]?.vertices[0];
const offers = params
.base()
.filter((point) => !head || !samePoint(point, head))
.map((point) => ({ point, note: "지점에 붙음" }));
network.lines.forEach((line, i) => {
if (i !== active)
for (const point of line.vertices) offers.push({ point, note: "선 꺾임점에 붙음" });
@@ -329,23 +330,24 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
params.onMode();
}
/** [긋기] 첫 누름 — 선 끝 이어 긋기 · 종점(뒤집어 긋기) · 시점 · 선 위 갈래. 참 = 시작함. */
/** [긋기] 첫 누름 — 선 끝 이어 긋기 · 지점 · 선 위 갈래. 참 = 시작함. */
function begin(px: number, py: number, map: ScreenMap): boolean {
const network = params.network();
const { starts, ends } = params.base();
const points = params.base();
const gap = (point: Vertex): number => screenGap(map, point, px, py);
const nearest = <T extends { gap: number }>(items: T[]): T | undefined =>
items.filter((item) => item.gap <= SNAP_PX).sort((a, b) => a.gap - b.gap)[0];
const onBase = (list: Vertex[], point: Vertex): boolean =>
list.some((p) => samePoint(p, point));
strokeStart = network;
// 1) 선 끝 — 꼬리(종점에 닿은 꼬리 빼고) · 갈래 없는 선 머리(시점에 놓인 머리 빼고)
// 1) 선 끝 — 꼬리 · 갈래 없는 선 머리(지점에 놓인 끝 빼고 — 지점 누름 = 새 선 · 갈래)
const tips: Array<{ line: number; head: boolean; gap: number }> = [];
network.lines.forEach((line, i) => {
const tail = line.vertices[line.vertices.length - 1];
const head = line.vertices[0];
if (tail && !onBase(ends, tail)) tips.push({ line: i, head: false, gap: gap(tail) });
if (line.vertices.length > 1 && !line.branch && !onBase(starts, head))
if (tail && line.vertices.length > 1 && !onBase(points, tail))
tips.push({ line: i, head: false, gap: gap(tail) });
if (line.vertices.length > 1 && !line.branch && !onBase(points, head))
tips.push({ line: i, head: true, gap: gap(head) });
});
const tip = nearest(tips);
@@ -361,22 +363,18 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
[active, flip] = [tip.line, tip.head];
return true;
}
// 2) 시점 · 종점
const base = nearest([
...starts.map((point) => ({ point, fromEnd: false, gap: gap(point) })),
...ends.map((point) => ({ point, fromEnd: true, gap: gap(point) })),
]);
// 2) 지점 — 이미 선이 놓인 지점 = 그 점의 갈래 · 아니면 새 본선(No.0 = 이 지점)
const base = nearest(points.map((point) => ({ point, gap: gap(point) })));
if (base) {
const { point, fromEnd } = base;
const owner = fromEnd
? -1
: network.lines.findIndex((line) => line.vertices.some((v) => samePoint(v, point)));
const { point } = base;
const owner = network.lines.findIndex(
(line) => line.vertices.length > 1 && line.vertices.some((v) => samePoint(v, point)),
);
if (owner >= 0) {
// 이미 선이 놓인 시점 = 그 점의 갈래
const vertex = network.lines[owner].vertices.findIndex((v) => samePoint(v, point));
params.commit(addBranch(network, owner, vertex));
} else params.commit(newLine(network, point));
[active, flip] = [params.network().lines.length - 1, fromEnd];
[active, flip] = [params.network().lines.length - 1, false];
return true;
}
// 3) 선 위 · 꺾임점 = 갈래
@@ -393,7 +391,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
return true;
}
strokeStart = null;
showToast("시점 · 종점 · 선 끝 · 선 위를 눌러 시작합니다.", "info");
showToast("지점 · 선 끝 · 선 위를 눌러 시작합니다.", "info");
return false;
}
@@ -436,7 +434,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
const layer: PlanMapLayer = {
draw(context, map) {
const network = params.network();
paintNetwork(context, map, network, params.base(), active, params.heightAt(), () => {
paintNetwork(context, map, network, active, params.heightAt(), () => {
const from = active === null ? null : lastOf(active);
if (from && preview)
paintPreview(
@@ -2,9 +2,11 @@
* D03_PlanAnalysis_UI_DrawPaint.ts
* D03 손 긋기 겹 그림 · 집기(PLAN 562-3 · 580) — `D03_PlanAnalysis_UI_Draw`(긋기) ·
* `D03_PlanAnalysis_UI_Edit`(고르기 · 고치기)가 같이 씀. 그림 = 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 ·
* 시점 · 종점 · 미리보기(거리 · 가상 종단 기울기 % · °) · 집기 = 화면 px 둘레 선 위 자리.
* 미리보기(거리 · 가상 종단 기울기 % · °) · 집기 = 화면 px 둘레 선 위 자리.
* 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 기준지정과 같은 `pointColor(i)`.
* ========================================================================== */
import { pointColor } from "@ui/ui_template_palette";
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import {
distanceAlong,
@@ -16,10 +18,15 @@ import {
type HeightAt,
} from "./logic/D03_PlanAnalysis_Draw";
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { planColors, PLAN_STATION_INTERVAL_M, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
import {
planColors,
PLAN_STATION_INTERVAL_M,
type PlanMapLayer,
type ScreenMap,
} from "./D03_PlanAnalysis_UI_Map";
import type { DrawPreview } from "./D03_PlanAnalysis_UI_Draw";
/** 시점 · 종점 · 꺾임점에 붙는 반경(px). */
/** 지점 · 꺾임점에 붙는 반경(px). */
export const SNAP_PX = 12;
/** 선 위 누름으로 볼 반경(px). */
export const LINE_HIT_PX = 8;
@@ -78,18 +85,16 @@ export function lineAt(
return best;
}
/** 그은 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · (extra = 미리보기) · 시점(초록) · 종점(빨강).
/** 그은 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · (extra = 미리보기).
* `active` = 그리는 선(굵은 보라). */
export function paintNetwork(
context: CanvasRenderingContext2D,
map: ScreenMap,
network: PlanNetwork,
base: { starts: Vertex[]; ends: Vertex[] },
active: number | null,
z: HeightAt,
extra: () => void,
): void {
const { starts, ends } = base;
const colors = planColors();
context.lineCap = "round";
context.lineJoin = "round";
@@ -129,21 +134,35 @@ export function paintNetwork(
}
drawPieces(context, map, network);
extra();
// 시점(초록) · 종점(빨강)
for (const [points, fill] of [
[starts, "#16a34a"],
[ends, "#dc2626"],
] as const)
for (const point of points) {
const [sx, sy] = map.toScreen(point);
context.beginPath();
context.arc(sx, sy, 7, 0, Math.PI * 2);
context.fillStyle = fill;
context.fill();
context.lineWidth = 2;
context.strokeStyle = "#ffffff";
context.stroke();
}
}
/** 지점 핀 겹(585-1) — 꼭지 = 지점 · 둥근 머리 · 「지점 N」 · 화면 px 고정 크기 · 돌린 지도에서도 섬. */
export function pointLayer(points: () => Vertex[]): PlanMapLayer {
return {
draw(context, map) {
points().forEach((point, i) => {
const [sx, sy] = map.toScreen(point);
map.upright(context, sx, sy, () => {
const head = sy - 18;
context.beginPath();
context.moveTo(sx, sy);
context.lineTo(sx - 7, head + 5);
context.arc(sx, head, 9, Math.PI * 0.8, Math.PI * 0.2);
context.closePath();
context.fillStyle = pointColor(i);
context.fill();
context.lineWidth = 2;
context.strokeStyle = "#ffffff";
context.stroke();
context.beginPath();
context.arc(sx, head, 3.5, 0, Math.PI * 2);
context.fillStyle = "#ffffff";
context.fill();
});
planLabel(context, map, sx, sy - 38, `지점 ${i + 1}`, "bold 12px");
});
},
};
}
function segmentLabels(
+5 -6
View File
@@ -5,7 +5,7 @@
* 선 누름 = 그 선 · 조각 고름(굵게 · 좌측 「구역 · 연도」 그 줄 밝힘) · 빈 곳 = 고름 풂.
* 고른 선 위 누름 = 꺾임점 더함(그대로 끌면 옮김) · 꺾임점 끌기 = 옮김(붙은 갈래 첫 점 같이) ·
* 마크 끌기 = 연도 구분 옮김 · 오른쪽 누름 = 꺾임점 · 마크 지움 · Delete = 고른 선 지움.
* 잠금 = 시점 · 종점에 놓인 점(1단계 몫) · 갈래 첫 점(붙은 선 점을 끌면 같이 감).
* 잠금 = 지점에 놓인 점(1단계 몫) · 갈래 첫 점(붙은 선 점을 끌면 같이 감).
* 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음.
* 좌측 「고른 선」 칸 = 이름 · 길이 · [방향 바꾸기](측점 No.0 쪽 바꿈) · [선 지움].
* ========================================================================== */
@@ -36,7 +36,7 @@ import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysi
export interface PlanEditParams {
network: () => PlanNetwork;
base: () => { starts: Vertex[]; ends: Vertex[] };
base: () => Vertex[];
commit: (next: PlanNetwork) => void;
/** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */
show: (next: PlanNetwork) => void;
@@ -170,12 +170,11 @@ 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];
const { starts, ends } = params.base();
return [...starts, ...ends].some((p) => samePoint(p, point))
? "시점 · 종점 자리 점은 1단계(기준 지정)에서 옮깁니다."
return params.base().some((p) => samePoint(p, point))
? "지점 자리 점은 1단계(기준 지정)에서 옮깁니다."
: null;
}
+1 -1
View File
@@ -161,7 +161,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
label: "평면 도구",
hint: {
lines: [
"[긋기] 뒤 시점 · 종점 · 선 끝 · 선 위 누름 = 시작",
"[긋기] 뒤 지점 · 선 끝 · 선 위 누름 = 시작",
"누름 = 점 찍기",
"Enter = 긋기 끝",
"Esc = 긋기 취소",
+17 -16
View File
@@ -6,7 +6,7 @@
* 좌 = 지형(들어올 때 받은 범위가 시점 · 종점을 못 덮으면 바로 받기 · [범위 밖 더 받기]는 시점 · 종점 · 선이
* 받은 범위 밖일 때만) · 그리기([긋기] · [연도 구분] · 제약 칸 · 긋는 해 · 고른 선) ·
* 구역 · 연도(선 통째 해 · 조각마다 해 · 고른 줄 밝힘) · [저장] [초기화].
* 시점 · 종점 = D02 목록(여럿) · 고르기 · 고치기 = `D03_PlanAnalysis_UI_Edit`.
* 지점 = D02 지점 목록(핀 · 늘 맨 위 · 585-1) · 고르기 · 고치기 = `D03_PlanAnalysis_UI_Edit`.
*
* 데이터 3층(CLAUDE.md 5장) — 긋기 · 마크 · 연도 = 캐시(`d03-plan` {version: 2, network}) · [저장] =
* PUT plan/network(서버가 길이 · 측점 · 조각 · 연도 행을 다시 셈 · 응답으로 바꿈) · [초기화] = 빈 선 PUT.
@@ -48,6 +48,7 @@ import {
} from "./D03_PlanAnalysis_Api";
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw";
import { pointLayer } from "./D03_PlanAnalysis_UI_DrawPaint";
import { createPlanEdit } from "./D03_PlanAnalysis_UI_Edit";
import { createPlanLayers } from "./D03_PlanAnalysis_UI_Layers";
import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
@@ -86,8 +87,7 @@ const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M,
export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const projectId = currentProjectId();
let editable = false;
let starts: Vertex[] = [];
let ends: Vertex[] = [];
let points: Vertex[] = []; // 기준 지점(D02 · 585-1)
let epsg = 0;
let yearStart = new Date().getFullYear();
let drawYear = yearStart; // 긋는 해 — 새 조각 처음 해
@@ -104,7 +104,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const z = (x: number, y: number): number => (surface ? gridHeightAt(surface.grid, x, y) : NaN);
const draw = createPlanDraw({
base: () => ({ starts, ends }),
base: () => points,
network: () => network,
heightAt: () => z,
commit,
@@ -120,7 +120,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
});
const edit = createPlanEdit({
network: () => network,
base: () => ({ starts, ends }),
base: () => points,
commit,
show: (next) => {
network = normalizePieces(next, drawYear);
@@ -131,6 +131,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
onSelect: () => refresh(),
});
const measure = createPlanMeasure(() => z);
const pins = pointLayer(() => points);
const map = createPlanMap({
onUndo: undo,
onRedo: redo,
@@ -140,7 +141,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const backdrop = createPlanLayers({
projectId,
map,
points: () => [...starts, ...ends, ...allVertices()],
points: () => [...points, ...allVertices()],
});
map.root.append(backdrop.bar, backdrop.legend);
const main = document.createElement("div");
@@ -246,14 +247,15 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
draw.layer,
edit.layer,
...(measuring ? [measure.layer] : []),
pins,
]);
refresh();
}
/** 시점 · 종점 · 꺾임점이 받은 범위 밖인가(받은 범위 없음 = 밖 아님 — 그땐 [지형 범위 보기]). */
/** 지점 · 꺾임점이 받은 범위 밖인가(받은 범위 없음 = 밖 아님 — 그땐 [지형 범위 보기]). */
const outside = (): boolean =>
!!have &&
[...starts, ...ends, ...allVertices()].some(
[...points, ...allVertices()].some(
([x, y]) => x < have![0] || x > have![2] || y < have![1] || y > have![3],
);
@@ -276,7 +278,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
: !surface
? "받은 지형 없음 — 범위를 보고 받으세요(미리보기 길이 · 기울기는 지형을 받은 뒤)."
: outside()
? "시점 · 종점 또는 그은 선이 받은 지형 밖입니다 — 더 받아야 기울기를 셉니다."
? "지점 또는 그은 선이 받은 지형 밖입니다 — 더 받아야 기울기를 셉니다."
: "받은 지형 안";
if (failed && editable && !busy) extentButton.hidden = false;
extentButton.querySelector(".ui-btn__label")!.textContent = failed
@@ -572,7 +574,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
// 검증 창구 — 꼴 · 미리보기 · 제약 · 화면 자리 · 재기 · 회전 · 저장 시간(ORCA 판정)
(window as unknown as { __d03Plan?: unknown }).__d03Plan = {
network: () => network,
base: () => ({ starts, ends }),
base: () => points,
lineNames: () => lineNames(network.lines),
screenOf: map.screenOf,
preview: draw.preview,
@@ -612,26 +614,25 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
}
};
window.addEventListener("keydown", onKey);
map.setLayers([...backdrop.layers, draw.layer, edit.layer]);
map.setLayers([...backdrop.layers, draw.layer, edit.layer, pins]);
try {
editable = !!(await fetchSessionUser())?.is_forest_service;
draw.setEditable(editable);
const view = await fetchPlanNetwork(projectId);
const base = view.base;
if (!base.starts.length || !base.ends.length || !base.epsg) {
terrainText.textContent = "시점 · 종점이 비었습니다 — 1단계(기준 지정)에서 정하세요.";
if (!base.points.length || !base.epsg) {
terrainText.textContent = "지점이 비었습니다 — 1단계(기준 지정)에서 정하세요.";
return;
}
starts = base.starts.map((p): Vertex => [p.x, p.y]);
ends = base.ends.map((p): Vertex => [p.x, p.y]);
points = base.points.map((p): Vertex => [p.x, p.y]);
epsg = base.epsg;
takeServer(view);
drawYear = yearStart;
drawYearPick.setOptions(yearOptions(), String(drawYear));
const draft = readState<Draft>("d03-plan", projectId);
if (draft?.version === DRAFT_VERSION && draft.network?.lines?.length) network = draft.network;
map.update({ fit: [...starts, ...ends, ...allVertices()] });
map.update({ fit: [...points, ...allVertices()] });
refresh();
await loadSurface();
refresh();
@@ -23,7 +23,7 @@ def _read(rel):
STUBS = f"""
const el = (tag: string, o: any = {{}}) => ({{ tag, hidden: false, textContent: o.text ?? "",
className: o.className, dataset: {{}}, children: o.children ?? [] }});
className: o.className, dataset: {{}}, children: o.children ?? [], addEventListener() {{}} }});
const requestAnimationFrame = (f: () => void) => setTimeout(f, 0);
const API_BASE_URL = "";
(globalThis as any).fetch = async () => ({{ ok: true, json: async () => ({{ key: "fake-js-key" }}) }});
@@ -37,10 +37,10 @@ const routePaths = () => [[[35.68, 127.9], [35.64, 127.83]]];
"""
FAKE_KAKAO = """
class Fake { setMap() {} relayout() {} setBounds() {} extend() {} }
class Fake { setMap() {} relayout() {} setBounds() {} extend() {} setMapTypeId() {} }
(globalThis as any).kakao = { maps: { load: (f: () => void) => f(), LatLng: Fake,
LatLngBounds: Fake, Map: Fake, Polyline: Fake, Marker: Fake, CustomOverlay: Fake,
event: { addListener: () => {} } } };
MapTypeId: { ROADMAP: 1, HYBRID: 3 }, event: { addListener: () => {} } } };
"""
@@ -154,7 +154,7 @@ def test_소재지_역지오코딩(monkeypatch):
MAP_STUBS = """
const el = (tag: string, o: any = {}) => ({ tag, hidden: false, textContent: o.text ?? "",
className: o.className, dataset: {}, children: o.children ?? [] });
className: o.className, dataset: {}, children: o.children ?? [], addEventListener() {} });
const requestAnimationFrame = (f: () => void) => setTimeout(f, 0);
(globalThis as any).window = globalThis;
(globalThis as any).document = { createElement: () => ({}), head: { append: () => undefined } };
@@ -169,7 +169,9 @@ const overlay = () => ({ setMap: () => undefined });
load: (f: () => void) => f(), LatLng,
LatLngBounds: class { extend() {} },
Map: class { relayout() {} setBounds() { LOG.fit = true; }
setCenter(p: any) { LOG.center = [p.lng, p.lat]; } setLevel(l: number) { LOG.level = l; } },
setCenter(p: any) { LOG.center = [p.lng, p.lat]; } setLevel(l: number) { LOG.level = l; }
setMapTypeId() {} },
MapTypeId: { ROADMAP: 1, HYBRID: 3 },
Polyline: function () { LOG.lines++; return overlay(); },
Marker: function (o: any) { const m = { ...overlay(), o, at: o.position,
getPosition: () => m.at }; LOG.markers.push(m); return m; },
@@ -147,7 +147,7 @@ console.log(JSON.stringify([
STUBS = """
const el = (tag: string, o: any = {}) => ({ tag, hidden: false, textContent: o.text ?? "",
className: o.className, dataset: {}, children: o.children ?? [] });
className: o.className, dataset: {}, children: o.children ?? [], addEventListener() {} });
const requestAnimationFrame = (f: () => void) => setTimeout(f, 0);
const injected: string[] = [];
(globalThis as any).document = { createElement: () => ({}),
@@ -202,7 +202,8 @@ const on = (kind: string, o: any) => ({ setMap: (m: any) => m && LOG.overlays.pu
LatLng,
LatLngBounds: class { extend(p: any) { LOG.bounds.push([p.lat, p.lng]); } },
Map: class { constructor() { LOG.maps = (LOG.maps ?? 0) + 1; }
relayout() {} setBounds() { LOG.fit = true; } },
relayout() {} setBounds() { LOG.fit = true; } setMapTypeId() {} },
MapTypeId: { ROADMAP: 1, HYBRID: 3 },
Polyline: function (o: any) { return on("line", o); },
Marker: function (o: any) { return on("marker", o); },
CustomOverlay: function (o: any) { return on("label", o); },
+29 -20
View File
@@ -1,5 +1,5 @@
"""D02 기준 지정(PLAN 529-3 · 560-2 · 579-1) — 경위도 ↔ TM 변환 · 시점 · 종점 목록 정본 API ·
옛 한 점 꼴 감쌈 · 옛 연도 행 접기 · 저장된 좌표계 그대로.
"""D02 기준 지정(PLAN 529-3 · 560-2 · 579-1 · 585-1) — 경위도 ↔ TM 변환 · 지점 목록 하나 정본 API ·
옛 시점 · 종점 꼴 이어 붙임 · 옛 한 점 꼴 감쌈 · 옛 연도 행 접기 · 저장된 좌표계 그대로.
./venv/Scripts/python.exe -m pytest resources/tester/test_529_3_plan_points.py -q
"""
@@ -81,52 +81,61 @@ def api(monkeypatch):
def test_put_lists(api) -> None:
got = api.put(URL, json={"starts": [A, B], "ends": [C]})
got = api.put(URL, json={"points": [A, B, C]})
assert got.status_code == 200
data = got.json()
assert data["epsg"] == 5186 and len(data["starts"]) == 2 and len(data["ends"]) == 1
assert (data["starts"][0]["x"], data["starts"][0]["y"]) == pytest.approx(
assert data["epsg"] == 5186 and len(data["points"]) == 3
assert (data["points"][0]["x"], data["points"][0]["y"]) == pytest.approx(
lonlat_to_tm(127.10, 37.50, 5186)
)
end = data["ends"][0]
end = data["points"][2]
assert abs(end["lon"] - C["lon"]) < 1e-8 and abs(end["lat"] - C["lat"]) < 1e-8
stored = json.loads(api.state["project"]["plan_base_json"])
assert set(stored) == {"epsg", "starts", "ends"} and set(stored["starts"][1]) == {"x", "y"}
assert set(stored) == {"epsg", "points"} and set(stored["points"][1]) == {"x", "y"}
assert api.state["rows"] == {} # 연도 행은 D02 가 안 만듦
def test_put_partial_and_empty(api) -> None:
data = api.put(URL, json={"starts": [A]}).json()
assert len(data["starts"]) == 1 and data["ends"] == [] and data["epsg"] == 5186
data = api.put(URL, json={"points": [A]}).json()
assert len(data["points"]) == 1 and data["epsg"] == 5186
data = api.put(URL, json={}).json()
assert data == {"status": "success", "epsg": None, "starts": [], "ends": []}
assert api.put(URL, json={"starts": [A] * 21}).status_code == 422 # 상한 20
assert data == {"status": "success", "epsg": None, "points": []}
assert api.put(URL, json={"points": [A] * 40}).status_code == 200
assert api.put(URL, json={"points": [A] * 41}).status_code == 422 # 상한 40(옛 20 + 20)
def test_put_keeps_stored_epsg(api) -> None:
api.put(URL, json={"starts": [A], "ends": [C]})
api.put(URL, json={"points": [A, C]})
far = {"lon": 128.9, "lat": 37.5} # 평균이 128° 를 넘어도 저장된 5186 그대로
data = api.put(URL, json={"starts": [A], "ends": [far, far]}).json()
data = api.put(URL, json={"points": [A, far, far]}).json()
assert data["epsg"] == 5186
def test_get_joins_old_start_end_lists(api) -> None:
a, b, c = (lonlat_to_tm(p["lon"], p["lat"], 5186) for p in (A, B, C))
old = {"epsg": 5186, "starts": [{"x": a[0], "y": a[1]}, {"x": b[0], "y": b[1]}],
"ends": [{"x": c[0], "y": c[1]}]} # fmt: skip
api.state["project"]["plan_base_json"] = json.dumps(old)
data = api.get(URL).json()
assert [p["x"] for p in data["points"]] == pytest.approx([a[0], b[0], c[0]]) # 시점 뒤 종점
def test_get_wraps_old_single_points(api) -> None:
a, c = (lonlat_to_tm(p["lon"], p["lat"], 5186) for p in (A, C))
old = {"epsg": 5186, "start": {"x": a[0], "y": a[1]}, "end": {"x": c[0], "y": c[1]}}
api.state["project"]["plan_base_json"] = json.dumps(old)
data = api.get(URL).json()
assert [p["x"] for p in data["starts"]] == pytest.approx([a[0]])
assert [p["x"] for p in data["ends"]] == pytest.approx([c[0]])
assert [p["x"] for p in data["points"]] == pytest.approx([a[0], c[0]])
old["end"] = None
api.state["project"]["plan_base_json"] = json.dumps(old)
assert api.get(URL).json()["ends"] == []
assert [p["x"] for p in api.get(URL).json()["points"]] == pytest.approx([a[0]])
def test_put_forest_only(api) -> None:
api.state["session"] = {"role": "USER", "email": "lee@company.com", "company_id": 1}
assert api.put(URL, json={"starts": [A], "ends": [B]}).status_code == 403
assert api.put(URL, json={"points": [A, B]}).status_code == 403
api.state["session"] = {"role": "SYSTEM_ADMIN", "email": "admin@x.com", "company_id": None}
assert api.put(URL, json={"starts": [A], "ends": [B]}).status_code == 200
assert api.put(URL, json={"points": [A, B]}).status_code == 200
def test_get_folds_old_year_rows(api) -> None:
@@ -140,8 +149,8 @@ def test_get_folds_old_year_rows(api) -> None:
}
data = api.get(URL).json()
assert data["epsg"] == epsg
assert (data["starts"][0]["x"], data["ends"][0]["x"]) == pytest.approx((a[0], c[0]))
assert [p["x"] for p in data["points"]] == pytest.approx([a[0], c[0]])
def test_get_empty_new_project(api) -> None:
assert api.get(URL).json() == {"status": "success", "epsg": None, "starts": [], "ends": []}
assert api.get(URL).json() == {"status": "success", "epsg": None, "points": []}
+2 -2
View File
@@ -184,7 +184,7 @@ class _Db:
def client(monkeypatch):
db = _Db()
monkeypatch.setattr(net, "get_db_pool", lambda: db)
base = {"epsg": 5186, "starts": [{"x": 0.0, "y": 0.0}], "ends": [{"x": 90.0, "y": 180.0}]}
base = {"epsg": 5186, "points": [{"x": 0.0, "y": 0.0}, {"x": 90.0, "y": 180.0}]}
async def load_base(project_id):
return base
@@ -214,7 +214,7 @@ def test_get_saved_legacy_or_empty(client) -> None:
client.db.answers["FROM plan_networks"] = [{"network_json": json.dumps(_network())}]
got = client.get(URL).json()
assert got["legacy"] is False and len(got["derived"]["pieces"]) == 3
assert got["base"]["ends"] == [{"x": 90.0, "y": 180.0}] and got["year_start"] == 2026
assert got["base"]["points"][1] == {"x": 90.0, "y": 180.0} and got["year_start"] == 2026
def test_put_stores_network_pieces_years(client) -> None:
+6 -4
View File
@@ -31,8 +31,10 @@ class _Pool:
def client(tmp_path, monkeypatch):
plan = {
"epsg": 5186,
"starts": [{"lon": 0, "lat": 0, "x": P0[0], "y": P0[1]}],
"ends": [{"lon": 0, "lat": 0, "x": P2[0], "y": P2[1]}],
"points": [
{"lon": 0, "lat": 0, "x": P0[0], "y": P0[1]},
{"lon": 0, "lat": 0, "x": P2[0], "y": P2[1]},
],
}
async def load_base(project_id):
@@ -56,12 +58,12 @@ def test_extent_uses_whole_ends(client) -> None:
got = client.get(f"{URL}/extent?margin_m=400").json()
m = 400.0
assert got["margin_m"] == m and got["have"] is None and got["covered"] is False
# 전체 시점 · 종점(D02 정본) — 연도 구간 아님
# 지점 모두(D02 정본) — 연도 구간 아님
assert got["box_m"] == [P0[0] - m, P0[1] - m, P2[0] + m, P2[1] + m]
extra = client.get(f"{URL}/extent", params={"extra": json.dumps([[2000, 0]])}).json()
assert extra["box_m"][2] == 2000 + extra["margin_m"]
assert client.get(f"{URL}/extent", params={"extra": "[1]"}).status_code == 400
client.plan["ends"] = [] # 종점 빔 — 1단계로
client.plan["points"] = [] # 지점 빔 — 1단계로
assert client.get(f"{URL}/extent").status_code == 400
+109
View File
@@ -0,0 +1,109 @@
"""585-1 공용 카카오 지도 = ☰ [설정] 마우스 따름(요구 445 A1) — 휠 = 설정 확대 방향 한 단 · 마우스 자리 고정 ·
가운데 끌기 = 옮김 · 지점 색 이름표 · 차례 색 여섯 돌림(가짜 kakao.maps 로 node 에서 돌림).
./venv/Scripts/python.exe -m pytest resources/tester/test_585_1_kakao_mouse.py -q
"""
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
KMAP = "ui_template/ui_template_kakao_map.ts"
PREFS = "A00_Common/input_prefs.ts"
PALETTE = "ui_template/ui_template_palette.ts"
def _read(rel):
return (ROOT / rel).read_text(encoding="utf-8")
STUBS = """
const handlers: Record<string, any> = {};
const el = (tag: string, o: any = {}) => ({ tag, hidden: false, textContent: o.text ?? "",
className: o.className, dataset: {}, children: o.children ?? [], style: {} as any,
classList: { list: [] as string[], add(c: string) { this.list.push(c); } },
clientWidth: 400, clientHeight: 300,
getBoundingClientRect: () => ({ left: 10, top: 20 }),
setPointerCapture() {},
addEventListener(type: string, fn: any, o: any) { if (o?.capture) handlers[type] = fn; } });
const requestAnimationFrame = (f: () => void) => setTimeout(f, 0);
(globalThis as any).window = globalThis;
(globalThis as any).document = { createElement: () => ({}), head: { append: () => undefined } };
const API_BASE_URL = "";
(globalThis as any).fetch = async () => ({ ok: true, json: async () => ({ key: "k" }) });
const store: Record<string, string> = {};
(globalThis as any).localStorage = { getItem: (k: string) => store[k] ?? null };
const LOG: any = { levels: [], centers: [], labels: [] };
class LatLng { lat: number; lng: number;
constructor(lat: number, lng: number) { this.lat = lat; this.lng = lng; }
getLat() { return this.lat; } getLng() { return this.lng; } }
const overlay = () => ({ setMap: () => undefined });
(globalThis as any).kakao = { maps: {
load: (f: () => void) => f(), LatLng, Point: function (x: number, y: number) { return { x, y }; },
LatLngBounds: class { extend() {} },
Map: class { level = 7; relayout() {} setBounds() {} setMapTypeId() {} setCenter(p: any) {
LOG.centers.push([p.lng, p.lat]); }
getLevel() { return this.level; }
setLevel(l: number, o: any) { this.level = l; LOG.levels.push([l, o?.anchor?.lng, o?.anchor?.lat]); }
getProjection() { return { coordsFromContainerPoint: (p: any) => new LatLng(p.y, p.x) }; } },
MapTypeId: { ROADMAP: 1, HYBRID: 3 },
Polyline: function () { return overlay(); },
Marker: function () { return overlay(); },
CustomOverlay: function (o: any) { LOG.labels.push(o.content); return overlay(); },
event: { addListener: () => undefined },
} };
"""
SCRIPT = """
const m = editMap({ hint: "h", onMove: () => undefined });
m.set([{ key: "0", name: "지점 1", lon: 127.9, lat: 35.68, movable: true, color: "#123456" },
{ key: "1", name: "지점 2", lon: 127.8, lat: 35.6, movable: true }], []);
await new Promise((r) => setTimeout(r, 20));
const ev = (o: any) => ({ preventDefault() {}, stopPropagation() {}, ctrlKey: false, ...o });
const wheel = (deltaY: number, t: number) =>
handlers.wheel(ev({ deltaY, clientX: 110, clientY: 70, timeStamp: t }));
wheel(-100, 1000); // 기본 = 밂 → 앞으로 = 확대(단 −1)
wheel(-100, 1050); // 한 단 쉼(180 ms) 안 = 버림
store["aislo:pref:input-prefs"] = JSON.stringify({ wheelZoom: "pull" });
wheel(-100, 2000); // 당김 설정 → 앞으로 = 축소(단 +1)
wheel(100, 3000); // 당김 → 몸 쪽 = 확대
handlers.pointerdown(ev({ button: 0, pointerId: 1, clientX: 0, clientY: 0 })); // 왼쪽 = 카카오 몫
handlers.pointermove(ev({ pointerId: 1, buttons: 1, clientX: 5, clientY: 5 }));
handlers.pointerdown(ev({ button: 1, pointerId: 2, clientX: 50, clientY: 50 }));
handlers.pointermove(ev({ pointerId: 2, buttons: 4, clientX: 60, clientY: 45 }));
handlers.pointerup(ev({ pointerId: 2 }));
handlers.pointermove(ev({ pointerId: 2, buttons: 4, clientX: 90, clientY: 90 })); // 놓은 뒤 = 안 옮김
const [first, second] = LOG.labels;
console.log(JSON.stringify({ levels: LOG.levels, centers: LOG.centers,
colored: [first.style.background, first.classList.list], plain: second.style.background ?? null,
colors: Array.from({ length: 8 }, (_, i) => pointColor(i)) }));
"""
def test_휠_설정_방향_가운데_끌기_지점_색():
src = (
STUBS
+ IMPORTS.sub("", _read(PREFS))
+ IMPORTS.sub("", _read(PALETTE)).replace(
"getComputedStyle(document.documentElement).getPropertyValue(name).trim()", '""'
)
+ IMPORTS.sub("", _read(KMAP))
+ SCRIPT
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-"], input=src,
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
got = json.loads(result.stdout)
# 마우스 자리 = 상자 안 (100, 50) 고정 · 단마다 한 번
assert got["levels"] == [[6, 100, 50], [7, 100, 50], [6, 100, 50]]
# 가운데 끌기 (+10, −5) = 중심을 반대로 → 상자 가운데 (200, 150) − 민 만큼
assert got["centers"] == [[190, 155]]
assert got["colored"] == ["#123456", ["is-colored"]] and got["plain"] is None
# 여섯 색 차례 돌림 · 일곱째 = 첫째
assert len(set(got["colors"][:6])) == 6 and got["colors"][6:] == got["colors"][:2]
+7
View File
@@ -32,3 +32,10 @@
white-space: nowrap;
pointer-events: none;
}
/* 585-1 지점 색 이름표 — 바탕 = 지점 색(인라인) · 흰 글 */
.ui-kakao-map__label.is-colored {
border-color: #fff;
color: #fff;
font-weight: 700;
}
+77 -2
View File
@@ -7,26 +7,37 @@
* 길 선 = 부르는 쪽이 준 [위도, 경도] 목록 · 처음 한 번만 맞춤(153 · 그 뒤 보기 그대로)
* (244-10) SDK 못 받음(도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄
* (560-3) 바탕 종류 = `setType("ROADMAP" | "HYBRID")`(위성 + 지명) · 단추는 부르는 쪽 부품
* (585-1) 마우스 = ☰ [설정] 따름 — 휠 = 설정 확대 방향(카카오 제 휠은 잡는 단계에서 막음 · 한 단씩) ·
* 가운데 끌기 = 옮김 · 왼쪽 끌기 · 손가락 = 카카오 제 것 · 표지 색 = `MapPin.color`(이름표 바탕)
* ========================================================================== */
import "./ui_template_kakao_map.css";
import { API_BASE_URL } from "@config/config_frontend";
import { el } from "./ui_template_elements";
import { wheelEventIn } from "../A00_Common/input_prefs";
export const NO_MAP =
"카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 변경]으로 넣으세요";
const SDK_FAIL = "KakaoSdkFail";
const SDK = "https://dapi.kakao.com/v2/maps/sdk.js";
const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선
/** 휠 한 단 사이 쉼(ms) — 카카오 확대는 단(두 배)만 되어 잘게 오는 휠(트랙패드)을 한 단으로 묶음 */
const WHEEL_GAP_MS = 180;
interface LatLng {
getLat(): number;
getLng(): number;
}
interface KPoint {
x: number;
y: number;
}
interface KMap {
setBounds(bounds: object): void;
setCenter(p: LatLng): void;
setLevel(level: number): void;
setLevel(level: number, opts?: { anchor?: LatLng }): void;
getLevel(): number;
getProjection(): { coordsFromContainerPoint(p: KPoint): LatLng };
relayout(): void;
getBounds(): { contain(p: LatLng): boolean };
panTo(p: LatLng): void;
@@ -41,6 +52,7 @@ interface KMarker extends KOverlay {
interface KakaoMaps {
load(done: () => void): void;
LatLng: new (lat: number, lng: number) => LatLng;
Point: new (x: number, y: number) => KPoint;
LatLngBounds: new () => { extend(p: LatLng): void };
Map: new (box: HTMLElement, o: { center: LatLng; level: number }) => KMap;
Polyline: new (o: {
@@ -106,6 +118,8 @@ export interface MapPin {
movable: boolean;
/** 262-2 고정 시점 표지 — 끌기 · 맞춤 · 보기 이동에 안 쓰임 · 고르는 표지와 모양이 다름 */
fixed?: boolean;
/** 585-1 이름표 바탕 색(없으면 흰 바탕) */
color?: string;
}
export interface EditMap {
@@ -121,6 +135,66 @@ export interface EditMap {
setType: (type: MapType) => void;
}
/** 이름표 — 색이 있으면 그 바탕 · 흰 글 */
function label(p: MapPin): HTMLElement {
const tag = el("span", { className: "ui-kakao-map__label", text: p.name });
if (p.color) {
tag.classList.add("is-colored");
tag.style.background = p.color;
}
return tag;
}
/** 585-1 설정 따름 — 휠(잡는 단계 · 카카오에 안 넘김) = 설정 방향 한 단 · 마우스 자리 고정 ·
* 가운데 끌기 = 옮김. 왼쪽 끌기 · 손가락은 카카오 제 것. */
function followMouse(box: HTMLElement, map: KMap, maps: KakaoMaps): void {
const pointAt = (x: number, y: number): LatLng =>
map.getProjection().coordsFromContainerPoint(new maps.Point(x, y));
let lastWheel = 0;
box.addEventListener(
"wheel",
(event) => {
event.preventDefault();
event.stopPropagation();
if (event.timeStamp - lastWheel < WHEEL_GAP_MS) return;
lastWheel = event.timeStamp;
const rect = box.getBoundingClientRect();
const anchor = pointAt(event.clientX - rect.left, event.clientY - rect.top);
map.setLevel(map.getLevel() + (wheelEventIn(event) ? -1 : 1), { anchor });
},
{ capture: true, passive: false },
);
let drag: { id: number; x: number; y: number } | null = null;
box.addEventListener(
"pointerdown",
(event) => {
if (event.button !== 1) return;
event.preventDefault(); // 브라우저 자동 굴림 막음
event.stopPropagation();
drag = { id: event.pointerId, x: event.clientX, y: event.clientY };
box.setPointerCapture(event.pointerId);
},
{ capture: true },
);
box.addEventListener(
"pointermove",
(event) => {
if (!drag || event.pointerId !== drag.id) return;
if (!(event.buttons & 4)) return void (drag = null);
event.stopPropagation();
const [dx, dy] = [event.clientX - drag.x, event.clientY - drag.y];
[drag.x, drag.y] = [event.clientX, event.clientY];
map.setCenter(pointAt(box.clientWidth / 2 - dx, box.clientHeight / 2 - dy));
},
{ capture: true },
);
const stop = (event: PointerEvent): void => {
if (drag?.id === event.pointerId) drag = null;
};
box.addEventListener("pointerup", stop, { capture: true });
box.addEventListener("pointercancel", stop, { capture: true });
}
/** 고르는 지도 — 움직일 표지 끌기 = onMove · 지도 누름 = onClick(있을 때만) · 점 없으면 center(경도, 위도) */
export function editMap(opts: {
onMove: (key: string, lon: number, lat: number) => void;
@@ -159,6 +233,7 @@ export function editMap(opts: {
map = new maps.Map(box, { center: first, level: 7 });
mapTypes = maps.MapTypeId;
map.setMapTypeId(mapTypes[type]);
followMouse(box, map, maps);
if (opts.onClick)
maps.event.addListener(map, "click", (e) =>
opts.onClick!(e.latLng.getLng(), e.latLng.getLat()),
@@ -199,7 +274,7 @@ export function editMap(opts: {
marker,
new maps.CustomOverlay({
position: at,
content: el("span", { className: "ui-kakao-map__label", text: p.name }),
content: label(p),
yAnchor: 2.6,
}),
];
+3 -5
View File
@@ -118,15 +118,13 @@ export const ui_locales_de = {
DE_Link_NoRoad: ["우리 회사 임도 프로젝트가 없습니다.", "No road project in our company."],
/* --- D02 기준 지정(529-3) --- */
D02_Points_Title: ["시점 · 종점", "Starts · Ends"],
D02_Points_Title: ["지점 목록", "Points"],
D02_Points_Hint: [
"📍 로 고른 지점을 지도 누름으로 놓고 · 표지를 끌거나 경도 · 위도를 넣습니다. 연도 나누기는 노선분석에서.",
"Pick a point with 📍 and click the map, drag markers, or type lon · lat. Split by year in analysis.",
],
D02_Start: ["시점", "start"],
D02_End: ["종점", "end"],
D02_Add_Start: ["시점", "Start"],
D02_Add_End: ["종점", "End"],
D02_Point: ["지점", "Point"],
D02_Add_Point: ["지점", "Point"],
D02_Remove_Point: ["이 지점 지움", "Remove this point"],
D02_Lon: ["경도", "Lon"],
D02_Lat: ["위도", "Lat"],
+14
View File
@@ -36,3 +36,17 @@ export function themeColor(name: string, fallback: string): string {
if (value) cache.set(name, resolved);
return resolved;
}
/** 585-1 예정노선 지점 색 — 그림 색 여섯 차례 돌림(지점 1 · 2 …) · 기준지정 · 노선분석 같은 번호 같은 색 */
const POINT_COLORS = [
["--draw-chart-0", "#5b8def"],
["--draw-chart-1", "#34c38f"],
["--draw-chart-2", "#f1963b"],
["--draw-chart-3", "#9b6dde"],
["--draw-warning", "#b8860b"],
["--draw-danger", "#c0392b"],
] as const;
export function pointColor(index: number): string {
const [name, fallback] = POINT_COLORS[index % POINT_COLORS.length];
return themeColor(name, fallback);
}