Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
label: "평면 도구",
|
||||
hint: {
|
||||
lines: [
|
||||
"[긋기] 뒤 시점 · 종점 · 선 끝 · 선 위 누름 = 시작",
|
||||
"[긋기] 뒤 지점 · 선 끝 · 선 위 누름 = 시작",
|
||||
"누름 = 점 찍기",
|
||||
"Enter = 긋기 끝",
|
||||
"Esc = 긋기 취소",
|
||||
|
||||
@@ -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); },
|
||||
|
||||
@@ -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": []}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user