fix(D 예정노선): 첫 열림 지점 칸 [추가] 켬 — 지도 가운데가 처음 생길 때 칸 다시 그림 · Page 줄 수 698 → 647 (받기 지켜보기 · 둘레 상자 → UI_Terrain) (PLAN 597 · 요구 463-7)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,7 +41,6 @@ import {
|
||||
savePlanNetwork,
|
||||
startTerrain,
|
||||
type PlaceHit,
|
||||
type PlanJob,
|
||||
type PlanNetworkView,
|
||||
type PlanSurface,
|
||||
type TerrainAim,
|
||||
@@ -62,6 +61,14 @@ import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points";
|
||||
import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces";
|
||||
import { createPlanProps } from "./D03_PlanAnalysis_UI_Props";
|
||||
import { createPlanSite } from "./D03_PlanAnalysis_UI_Site";
|
||||
import {
|
||||
aroundSite,
|
||||
boxOfLonLat,
|
||||
errorText,
|
||||
nearSite,
|
||||
watchTerrain,
|
||||
type Site,
|
||||
} from "./D03_PlanAnalysis_UI_Terrain";
|
||||
import {
|
||||
emptyNetwork,
|
||||
fromServer,
|
||||
@@ -71,27 +78,12 @@ import {
|
||||
type PlanNetwork,
|
||||
type Vertex,
|
||||
} from "./logic/D03_PlanAnalysis_Plan";
|
||||
import { lonLatToTm, tmParams } from "./logic/D03_PlanAnalysis_Tm";
|
||||
|
||||
const POLL_MS = 1000;
|
||||
/** 받기가 끊김(서버 다시 켜짐 · 진행을 못 읽음) — [다시 받기]. */
|
||||
const INTERRUPTED = "끊김(서버 다시 켜짐 등)";
|
||||
/** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */
|
||||
const SITE_RADIUS_M = 1000;
|
||||
/** 조용히 받기가 덮는 거리 — 기준점에서 이보다 먼 지점 · 선은 안 받음(위치를 멀리 옮긴 뒤 옛 선 = 거대 받기 막음). */
|
||||
const COVER_MAX_M = 10000;
|
||||
/** 되돌리기 쌓는 수. */
|
||||
const UNDO_MAX = 100;
|
||||
/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름 · 위치 없음)은 안 읽음. */
|
||||
const DRAFT_VERSION = 5;
|
||||
|
||||
/** 기준점(위치 칸 · 등록과 한 칸 `projects.site_*`). */
|
||||
interface Site {
|
||||
lon: number;
|
||||
lat: number;
|
||||
address: string | null;
|
||||
}
|
||||
|
||||
/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선 + 위치(되돌리기 · 캐시 · 고침 판정). */
|
||||
interface Snap {
|
||||
network: PlanNetwork;
|
||||
@@ -104,10 +96,6 @@ interface Draft extends Snap {
|
||||
version: number;
|
||||
}
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
|
||||
const errorText = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const projectId = currentProjectId();
|
||||
let editable = false;
|
||||
@@ -213,7 +201,9 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const viewWatch: PlanMapLayer = {
|
||||
draw(context, screen) {
|
||||
const [w, h] = [context.canvas.clientWidth, context.canvas.clientHeight];
|
||||
const had = !!viewMid;
|
||||
viewMid = w ? screen.toMetric(...screen.unrotate(w / 2, h / 2)) : null;
|
||||
if (!had && viewMid) marks.render(); // 첫 그림 뒤 가운데가 생김 = 지점 칸 [추가] 켬
|
||||
},
|
||||
};
|
||||
const map = createPlanMap({
|
||||
@@ -281,16 +271,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const moved = (): boolean => !!site && JSON.stringify(site) !== JSON.stringify(savedSite);
|
||||
/** 받기 · 범위에 늘 지금 기준점(캐시)을 실음 — 받은 범위에서 멀면 서버가 물음(저장된 자리로 되돌아올 때도). */
|
||||
const center = (): TerrainAim["center"] => (site ? { lon: site.lon, lat: site.lat } : null);
|
||||
/** 기준점 둘레 ±1 km(작업 좌표) — 처음 맞춤 · 위치 옮김 · 임도망 맞춤. */
|
||||
const around = (at: Site | null): Vertex[] => {
|
||||
const tm = tmParams(epsg);
|
||||
if (!at || !tm) return [];
|
||||
const [x, y] = lonLatToTm(tm, at.lon, at.lat);
|
||||
return [
|
||||
[x - SITE_RADIUS_M, y - SITE_RADIUS_M],
|
||||
[x + SITE_RADIUS_M, y + SITE_RADIUS_M],
|
||||
];
|
||||
};
|
||||
const around = (at: Site | null): Vertex[] => aroundSite(epsg, at);
|
||||
const fitTo = (box: Vertex[]): void => {
|
||||
if (!box.length) return;
|
||||
map.update({ fit: box });
|
||||
@@ -369,10 +350,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
* 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */
|
||||
async function ensureCovered(again = false): Promise<void> {
|
||||
if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return;
|
||||
const [lo, hi] = around(site);
|
||||
const near = ([x, y]: Vertex): boolean =>
|
||||
!lo || Math.hypot(x - (lo[0] + hi[0]) / 2, y - (lo[1] + hi[1]) / 2) <= COVER_MAX_M;
|
||||
const extra = [...points, ...allVertices()].filter(near);
|
||||
const extra = nearSite(epsg, site, [...points, ...allVertices()]);
|
||||
if (!again && inside(extra)) return; // 받을 때 서버가 여유를 더함
|
||||
const key = JSON.stringify([have, extra]);
|
||||
if (key === lastTried) return;
|
||||
@@ -388,7 +366,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
redo: editable && redoStack.length > 0,
|
||||
view3d: !!surface && network.lines.some((line) => line.vertices.length > 1),
|
||||
measure: measuring,
|
||||
yearMark: editable && network.lines.some((line) => line.vertices.length > 1),
|
||||
});
|
||||
map.redraw();
|
||||
props.render();
|
||||
@@ -466,16 +443,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
/** 임도망 올리기 — 지도 맞춤 = 주소지 둘레 ∪ 잘린 임도 상자(없으면 주소지). */
|
||||
async function uploadRoads(files: File[]): Promise<void> {
|
||||
const info = await backdrop.uploadRoads(files);
|
||||
const tm = tmParams(epsg);
|
||||
if (!info) return;
|
||||
const roads: Vertex[] =
|
||||
tm && info.bounds
|
||||
? [
|
||||
lonLatToTm(tm, info.bounds[0], info.bounds[1]),
|
||||
lonLatToTm(tm, info.bounds[2], info.bounds[3]),
|
||||
]
|
||||
: [];
|
||||
fitTo([...around(site), ...roads]);
|
||||
if (info) fitTo([...around(site), ...boxOfLonLat(epsg, info.bounds)]);
|
||||
}
|
||||
|
||||
/** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */
|
||||
@@ -488,35 +456,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
await ensureCovered(); // 받는 동안 옮긴 지점 · 선 · 보기
|
||||
}
|
||||
|
||||
/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 지도 귀퉁이 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 ·
|
||||
* 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */
|
||||
async function watch(): Promise<PlanJob | null> {
|
||||
busy = true;
|
||||
circle.set(null, "지형");
|
||||
circle.root.hidden = false;
|
||||
refresh();
|
||||
try {
|
||||
for (;;) {
|
||||
if (!root.isConnected) return null;
|
||||
const job = (await fetchPlanProgress(projectId)).terrain;
|
||||
circle.set(job.percent / 100, `지형 ${job.message ?? ""}`.trim());
|
||||
circle.root.title = `지형 받기 ${job.percent}% · ${job.message ?? ""}`;
|
||||
if (!job.running) {
|
||||
const cut = !job.error && (job.interrupted || job.percent < 100);
|
||||
if (job.error) showToast(job.error, "error");
|
||||
return cut ? { ...job, error: INTERRUPTED } : job;
|
||||
}
|
||||
await sleep(POLL_MS);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
||||
return { running: false, percent: 0, message: null, error: INTERRUPTED };
|
||||
} finally {
|
||||
busy = false;
|
||||
circle.root.hidden = true;
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
const watch = () =>
|
||||
watchTerrain({
|
||||
projectId,
|
||||
alive: () => root.isConnected,
|
||||
circle,
|
||||
setBusy: (on) => {
|
||||
busy = on;
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
|
||||
async function loadSurface(): Promise<void> {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Terrain.ts
|
||||
* D 예정노선 받기 이음(PLAN 597 · 요구 463-7) — 페이지에서 뗀 조각: 기준점 꼴 · 둘레 상자 · 받기 지켜보기(서클).
|
||||
* 상태는 페이지가 쥐고 여기는 인자로만 받음(화면 요소 만들기 없음 · 서클은 부품 그대로).
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import type { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { fetchPlanProgress, type PlanJob } from "./D03_PlanAnalysis_Api";
|
||||
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import { lonLatToTm, tmParams } from "./logic/D03_PlanAnalysis_Tm";
|
||||
|
||||
const POLL_MS = 1000;
|
||||
/** 받기가 끊김(서버 다시 켜짐 · 진행을 못 읽음) — [다시 받기]. */
|
||||
const INTERRUPTED = "끊김(서버 다시 켜짐 등)";
|
||||
/** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */
|
||||
const SITE_RADIUS_M = 1000;
|
||||
/** 조용히 받기가 덮는 거리 — 기준점에서 이보다 먼 지점 · 선은 안 받음(위치를 멀리 옮긴 뒤 옛 선 = 거대 받기 막음). */
|
||||
const COVER_MAX_M = 10000;
|
||||
|
||||
/** 기준점(위치 칸 · 등록과 한 칸 `projects.site_*`). */
|
||||
export interface Site {
|
||||
lon: number;
|
||||
lat: number;
|
||||
address: string | null;
|
||||
}
|
||||
|
||||
export const sleep = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
|
||||
export const errorText = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
/** 기준점 둘레 ±1 km(작업 좌표) — 처음 맞춤 · 위치 옮김 · 임도망 맞춤. */
|
||||
export function aroundSite(epsg: number, at: Site | null): Vertex[] {
|
||||
const tm = tmParams(epsg);
|
||||
if (!at || !tm) return [];
|
||||
const [x, y] = lonLatToTm(tm, at.lon, at.lat);
|
||||
return [
|
||||
[x - SITE_RADIUS_M, y - SITE_RADIUS_M],
|
||||
[x + SITE_RADIUS_M, y + SITE_RADIUS_M],
|
||||
];
|
||||
}
|
||||
|
||||
/** 경위도 상자 [경도1, 위도1, 경도2, 위도2] → 작업 좌표 두 점(없음 · 띠 모름 = 빈 목록). */
|
||||
export function boxOfLonLat(epsg: number, bounds: number[] | null | undefined): Vertex[] {
|
||||
const tm = tmParams(epsg);
|
||||
if (!tm || !bounds) return [];
|
||||
return [lonLatToTm(tm, bounds[0], bounds[1]), lonLatToTm(tm, bounds[2], bounds[3])];
|
||||
}
|
||||
|
||||
/** 기준점 둘레 상자 가운데에서 `COVER_MAX_M` 안인 점만 남김(기준점 없음 = 모두). */
|
||||
export function nearSite(epsg: number, at: Site | null, list: Vertex[]): Vertex[] {
|
||||
const [lo, hi] = aroundSite(epsg, at);
|
||||
if (!lo) return list;
|
||||
return list.filter(
|
||||
([x, y]) => Math.hypot(x - (lo[0] + hi[0]) / 2, y - (lo[1] + hi[1]) / 2) <= COVER_MAX_M,
|
||||
);
|
||||
}
|
||||
|
||||
export interface WatchParams {
|
||||
projectId: string;
|
||||
alive: () => boolean;
|
||||
circle: ReturnType<typeof createProgressCircle>;
|
||||
/** 받는 중 켬끔(페이지 `busy`) 뒤 화면 갱신. */
|
||||
setBusy: (on: boolean) => void;
|
||||
}
|
||||
|
||||
/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 지도 귀퉁이 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 ·
|
||||
* 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */
|
||||
export async function watchTerrain(params: WatchParams): Promise<PlanJob | null> {
|
||||
const { projectId, alive, circle, setBusy } = params;
|
||||
setBusy(true);
|
||||
circle.set(null, "지형");
|
||||
circle.root.hidden = false;
|
||||
try {
|
||||
for (;;) {
|
||||
if (!alive()) return null;
|
||||
const job = (await fetchPlanProgress(projectId)).terrain;
|
||||
circle.set(job.percent / 100, `지형 ${job.message ?? ""}`.trim());
|
||||
circle.root.title = `지형 받기 ${job.percent}% · ${job.message ?? ""}`;
|
||||
if (!job.running) {
|
||||
const cut = !job.error && (job.interrupted || job.percent < 100);
|
||||
if (job.error) showToast(job.error, "error");
|
||||
return cut ? { ...job, error: INTERRUPTED } : job;
|
||||
}
|
||||
await sleep(POLL_MS);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
||||
return { running: false, percent: 0, message: null, error: INTERRUPTED };
|
||||
} finally {
|
||||
circle.root.hidden = true;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user