Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_SheetTiles.ts
T
eomsangdonandClaude Opus 5.5 bc1d5165ff feat(D 예정노선): 도엽 조각 — 공용 창고 storage/_shared/sheets · 보이는 도엽만 받기 (PLAN 606-1 · 606-2 · 요구 477)
- 새 D03_PlanAnalysis_SheetTiles.py — 도엽 도곽으로 자른 선 겹 조각(등고 · 계곡 = 전국 등고선 · 세류 · 물 면 · 도로 · 건물 · 지명 = 도엽 zip) · zip 은 프로젝트 · 공용 지형 창고에서 복사(원본 안 지움) 뒤 받기 · 빈 도엽 = 빈 조각 · 받기 실패 저장 안 함 · B04 함수 부르기만
- 라우터 길 둘 — 보이는 도엽 목록(bbox) · 조각 하나
- 새 UI_SheetTiles.ts — 보기 멈춘 뒤 보이는 도엽 · 없는 조각만 하나씩 · 12 장 이하 다 · 13~30 계곡선만 · 넘음 숨김 · 서클
- UI_Layers — 도엽 선 겹 원천을 조각으로 · 시험 606 새 · 475 옛 겹 길 단언 바꿈

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:14:11 +09:00

199 lines
7.5 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_SheetTiles.ts
* D03 수치지형도 선 겹 = 도엽 조각(PLAN 606 · 요구 477) — 위성처럼 지도 보기에 보이는 도엽만.
* 보기 멈춘 뒤(`VIEW_IDLE_MS`) 보이는 도엽 목록(서버 · 가운데부터) → 켠 겹의 없는 조각만 하나씩 받음
* (등고 먼저 · 보조 겹 뒤) → 받을 때마다 `onChange`(겹이 합친 자료를 다시 얹음).
* 보이는 도엽 12 장 이하 = 다 · 13~30 = 계곡선만(등고선 자리에 · 그 밖 선 겹 빔) · 넘음 = 선 겹 숨김.
* 받은 조각은 기억(최근 도엽 `KEEP_SHEETS` 장) — 다시 오면 즉시 · 받는 동안 서클(`track`).
* 지형 받기와 따로 — 보기만 옮겨도 조각은 받음 · 지형(받은 범위)은 안 받음(596-4 그대로).
* ========================================================================== */
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { fetchPlanSheetTile, fetchPlanSheetTiles } from "./D03_PlanAnalysis_Api";
import { WATER_KEYS, type PlanLayerKey } from "./D03_PlanAnalysis_UI_LayerDefs";
import type { ScreenMap } from "./D03_PlanAnalysis_UI_Map";
/** 겹 → 도엽 조각 이름(서버 `TILE_NAMES`). */
export const SHEET_TILE_NAMES: Partial<Record<PlanLayerKey, string[]>> = {
contour: ["등고선"],
stream: ["하천중심선"],
road: ["도로중심선"],
forestRoad: ["도로중심선"],
water: WATER_KEYS,
building: ["건물"],
place: ["지명"],
};
export const FULL_MAX = 12;
export const INDEX_MAX = 30;
const VIEW_IDLE_MS = 800;
const KEEP_SHEETS = 24;
const CHANGE_MS = 150;
export type SheetMode = "all" | "index" | "hidden";
export interface SheetTiles {
/** 바탕 그릴 때마다 — 보기가 멈추면 보이는 도엽을 다시 셈. */
view: (m: ScreenMap, meta: VWorldMeta) => void;
/** 겹 조각 이름의 보이는 도엽 합(모드대로 · 없는 조각은 받기 시작). */
collection: (name: string) => GeoJsonCollection;
state: () => {
mode: SheetMode;
count: number;
sheets: string[];
cached: number;
loading: boolean;
failed: string[];
/** 조각마다 받은 시간(ms · 마지막 20). */
ms: Array<[string, number]>;
};
}
export function createSheetTiles(opts: {
projectId: string;
track: <T>(job: Promise<T>) => Promise<T>;
/** 지금 켠 겹의 조각 이름. */
wanted: () => string[];
onChange: (names: Set<string>) => void;
}): SheetTiles {
const { projectId, track } = opts;
const cache = new Map<string, GeoJsonCollection>();
const failed = new Set<string>();
const ms: Array<[string, number]> = [];
let recent: string[] = [];
let sheets: string[] = [];
let count = 0;
let mode: SheetMode = "all";
let lastBox = "";
let timer = 0;
let turn = 0;
let loading = false;
/** 바뀐 조각 이름 — 잠깐 모아 한 번에 알림(겹마다 합친 자료를 다시 얹는 일 줄임). */
const dirty = new Set<string>();
let flush = 0;
function changed(names: string[]): void {
for (const name of names) dirty.add(name);
window.clearTimeout(flush);
flush = window.setTimeout(() => {
const names = new Set(dirty);
dirty.clear();
opts.onChange(names);
}, CHANGE_MS);
}
/** 모드에 따른 실제 조각 — 계곡선만이면 등고선 = 계곡선 · 그 밖 = 없음. */
const source = (name: string): string | null =>
mode === "hidden" ? null : mode === "all" ? name : name === "등고선" ? "계곡선" : null;
function nextJob(): [string, string] | null {
const names = [...new Set(opts.wanted())].map(source).filter((n): n is string => !!n);
// 등고 먼저 · 보조 겹(zip) 뒤
names.sort(
(a, b) =>
Number(!a.endsWith("고선") && a !== "계곡선") -
Number(!b.endsWith("고선") && b !== "계곡선"),
);
for (const name of names)
for (const sheet of sheets) {
const key = `${sheet}/${name}`;
if (!cache.has(key) && !failed.has(key)) return [sheet, name];
}
return null;
}
/** 없는 조각을 하나씩 — 목록이 바뀌면(차례) 남은 것은 새 목록으로. */
async function pull(): Promise<void> {
if (loading) return;
loading = true;
try {
for (let job = nextJob(); job; job = nextJob()) {
const [sheet, name] = job;
const key = `${sheet}/${name}`;
const started = performance.now();
try {
cache.set(key, await fetchPlanSheetTile(projectId, sheet, name));
} catch {
failed.add(key); // 이번 화면에선 다시 안 받음(다음 목록 때 다시)
}
ms.push([key, Math.round(performance.now() - started)]);
if (ms.length > 20) ms.shift();
if (sheets.includes(sheet)) changed([name === "계곡선" ? "등고선" : name]);
}
} finally {
loading = false;
}
}
const kick = (): void => void (nextJob() && !loading && track(pull()));
async function list(bbox: [number, number, number, number]): Promise<void> {
const mine = ++turn;
const got = await fetchPlanSheetTiles(projectId, bbox).catch(() => null);
if (!got || mine !== turn) return;
const before = `${mode}|${sheets.join()}`;
count = got.count;
sheets = got.sheets;
mode = count <= FULL_MAX ? "all" : count <= INDEX_MAX ? "index" : "hidden";
failed.clear();
recent = [...new Set([...sheets, ...recent])].slice(0, Math.max(KEEP_SHEETS, sheets.length));
for (const key of cache.keys()) if (!recent.includes(key.split("/")[0])) cache.delete(key);
if (`${mode}|${sheets.join()}` !== before) changed(Object.values(SHEET_TILE_NAMES).flat());
kick();
}
return {
view: (m, meta) => {
const { width, height } = m.view;
if (!width || !height) return;
// 그림 px 네 귀 → 작업 좌표 → 경위도(위성 틀 1차) · 돌린 지도 = 대각선만큼 넓힘
const grow = m.uprightRad ? Math.SQRT2 : 1;
const corners = [
[0, 0],
[width, 0],
[0, height],
[width, height],
].map(([px, py]) =>
m.toMetric(width / 2 + (px - width / 2) * grow, height / 2 + (py - height / 2) * grow),
);
const lon = (x: number) =>
meta.lon_min +
((x - meta.x_min) / (meta.width_meters || 1)) * (meta.lon_max - meta.lon_min);
const lat = (y: number) =>
meta.lat_min +
((y - meta.y_min) / (meta.height_meters || 1)) * (meta.lat_max - meta.lat_min);
const lons = corners.map(([x]) => lon(x));
const lats = corners.map(([, y]) => lat(y));
const bbox: [number, number, number, number] = [
Math.min(...lons),
Math.min(...lats),
Math.max(...lons),
Math.max(...lats),
];
const tag = bbox.map((v) => v.toFixed(4)).join();
if (tag === lastBox) return;
lastBox = tag;
window.clearTimeout(timer);
timer = window.setTimeout(() => void list(bbox), VIEW_IDLE_MS);
},
collection: (name) => {
const src = source(name);
if (src) kick();
return {
type: "FeatureCollection",
features: src
? sheets.flatMap((sheet) => cache.get(`${sheet}/${src}`)?.features ?? [])
: [],
} as GeoJsonCollection;
},
state: () => ({
mode,
count,
sheets: [...sheets],
cached: cache.size,
loading,
failed: [...failed],
ms: [...ms],
}),
};
}