diff --git a/D02_PlanBase/D02_PlanBase_Router.py b/D02_PlanBase/D02_PlanBase_Router.py index 5c8c750e4..2aa235e7f 100644 --- a/D02_PlanBase/D02_PlanBase_Router.py +++ b/D02_PlanBase/D02_PlanBase_Router.py @@ -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: diff --git a/D02_PlanBase/D02_PlanBase_UI_Page.ts b/D02_PlanBase/D02_PlanBase_UI_Page.ts index 57fe084df..8dd8ef89b 100644 --- a/D02_PlanBase/D02_PlanBase_UI_Page.ts +++ b/D02_PlanBase/D02_PlanBase_UI_Page.ts @@ -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>; +type Points = Array; const KOREA_CENTER: [number, number] = [127.8, 36.3]; const DIGITS = 7; // 경위도 소수 7 자리 ≈ 1 cm @@ -70,15 +70,12 @@ async function request(path: string, init?: RequestInit): Promise { return data as T; } -const SIDES: Side[] = ["starts", "ends"]; - /** 서버 목록 → 표 줄(빈 목록 = 빈 줄 하나) */ function boundaryPoints(view: PointsView | null): Points { - const pick = (list: ServerPoint[] = []): Array => - 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 { @@ -108,22 +105,13 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { 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 { 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 { 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 { /** 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 { 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 { 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 { variant: "ghost", size: "sm", onClick: () => { - active = keyOf(side, i); + active = i; drawTable(); }, }); @@ -241,39 +227,35 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { 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 { async function store(): Promise { showLoadingOverlay(); try { - const body = { starts: points.starts.filter(Boolean), ends: points.ends.filter(Boolean) }; + const body = { points: points.filter(Boolean) }; adopt( await request(`/projects/${projectId}/plan/points`, { method: "PUT", @@ -321,8 +303,8 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { adopt(await request(`/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"); diff --git a/D02_PlanBase/D02_PlanBase_UI_Style.css b/D02_PlanBase/D02_PlanBase_UI_Style.css index 7c4c9f289..05df7de07 100644 --- a/D02_PlanBase/D02_PlanBase_UI_Style.css +++ b/D02_PlanBase/D02_PlanBase_UI_Style.css @@ -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)); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 41b875a89..ba241cdc4 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -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 { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Network.py b/D03_PlanAnalysis/D03_PlanAnalysis_Network.py index e43374286..f98c0e247 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Network.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Network.py @@ -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() diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 3a4aa5acd..2d2acf04e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -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 diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index 7cd7b839c..31927ecf2 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -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 = (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( diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index e5f54324d..9173d9be6 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -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( diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts index 170a20e14..7ac980f1e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts @@ -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; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index 282927445..ac8828bb4 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -161,7 +161,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { label: "평면 도구", hint: { lines: [ - "[긋기] 뒤 시점 · 종점 · 선 끝 · 선 위 누름 = 시작", + "[긋기] 뒤 지점 · 선 끝 · 선 위 누름 = 시작", "누름 = 점 찍기", "Enter = 긋기 끝", "Esc = 긋기 취소", diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 7d3f0670b..00e4952f6 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -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 { 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 { 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 { }); 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 { 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 { 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 { 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 { : !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 { // 검증 창구 — 꼴 · 미리보기 · 제약 · 화면 자리 · 재기 · 회전 · 저장 시간(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 { } }; 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("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(); diff --git a/resources/tester/test_244_10_b08_milestone_tailscale.py b/resources/tester/test_244_10_b08_milestone_tailscale.py index 2488e14ab..7fadfee58 100644 --- a/resources/tester/test_244_10_b08_milestone_tailscale.py +++ b/resources/tester/test_244_10_b08_milestone_tailscale.py @@ -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: () => {} } } }; """ diff --git a/resources/tester/test_244_13_b08_milestone_place_leg.py b/resources/tester/test_244_13_b08_milestone_place_leg.py index bcdbf2dfd..6560796cf 100644 --- a/resources/tester/test_244_13_b08_milestone_place_leg.py +++ b/resources/tester/test_244_13_b08_milestone_place_leg.py @@ -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; }, diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index 7a8cefdc8..6f7c06d64 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -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); }, diff --git a/resources/tester/test_529_3_plan_points.py b/resources/tester/test_529_3_plan_points.py index 4de07be15..58a896d80 100644 --- a/resources/tester/test_529_3_plan_points.py +++ b/resources/tester/test_529_3_plan_points.py @@ -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": []} diff --git a/resources/tester/test_530_2_plan_analyze.py b/resources/tester/test_530_2_plan_analyze.py index 4fc5ff5fa..e48d0400f 100644 --- a/resources/tester/test_530_2_plan_analyze.py +++ b/resources/tester/test_530_2_plan_analyze.py @@ -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: diff --git a/resources/tester/test_530_2_plan_router.py b/resources/tester/test_530_2_plan_router.py index bc8953865..c47ed35c0 100644 --- a/resources/tester/test_530_2_plan_router.py +++ b/resources/tester/test_530_2_plan_router.py @@ -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 diff --git a/resources/tester/test_585_1_kakao_mouse.py b/resources/tester/test_585_1_kakao_mouse.py new file mode 100644 index 000000000..dd54c0d82 --- /dev/null +++ b/resources/tester/test_585_1_kakao_mouse.py @@ -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 = {}; +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 = {}; +(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] diff --git a/ui_template/ui_template_kakao_map.css b/ui_template/ui_template_kakao_map.css index 5a6ccad0e..b0b327e5c 100644 --- a/ui_template/ui_template_kakao_map.css +++ b/ui_template/ui_template_kakao_map.css @@ -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; +} diff --git a/ui_template/ui_template_kakao_map.ts b/ui_template/ui_template_kakao_map.ts index 220a9b3cd..d9a176551 100644 --- a/ui_template/ui_template_kakao_map.ts +++ b/ui_template/ui_template_kakao_map.ts @@ -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, }), ]; diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index bdb9cfcfd..deac576cf 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -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"], diff --git a/ui_template/ui_template_palette.ts b/ui_template/ui_template_palette.ts index 4f04adf33..01e377d13 100644 --- a/ui_template/ui_template_palette.ts +++ b/ui_template/ui_template_palette.ts @@ -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); +}