Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_SheetTiles.ts
T
eomsangdonandClaude Opus 5.5 8ce7ef6cbe feat(D 예정노선): 등고선 = 줌별 벡터 조각 — 서버 조각 · 공용 창고 · 화면 고정 소스 (PLAN 615-1 · 615-2 · 요구 496)
- 서버: 새 common_util_contour_tiles — 조각(z8~15) = 전국 등고선 상자 읽기 · 줌 간격 거름 · 단순화 · 3857 · MVT(겹 contour · h · k) · 창고 storage/_shared/contour_tiles/v1_<지문>/z/x/y.pbf · 잠금 · .part 바꿈 · 빈 조각 저장
- 줌 간격: z8 1000 · z9 500(화면 꼭짓점 z13 이하 실측) · z10 200 · z11 100 · z12 50 · z13 25 · z14 10(+25) · z15 5 m
- 길: GET /api/projects/{id}/plan/contour/{z}/{x}/{y}.pbf
- 화면: contourTileSource(위성처럼 고정 · minzoom 8 · maxzoom 15) · 등고 겹 셋 = 벡터 조각 · 받는 동안 서클 · GL 이면 606 등고 도엽 조각 안 받음(옛 캔버스는 그대로) · 주곡선 간격 셈 걷음
- 시험: test_615_contour_tiles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:06:48 +09:00

212 lines
8.0 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_SheetTiles.ts
* D03 수치지형도 선 겹 = 도엽 조각(PLAN 606 · 요구 477) — 위성처럼 지도 보기에 보이는 도엽만.
* 보기 멈춘 뒤(`VIEW_IDLE_MS`) 보이는 도엽 목록(서버 · 가운데부터) → 켠 겹의 없는 조각만 하나씩 받음
* (등고 먼저 · 보조 겹 뒤) → 받을 때마다 `onChange`(겹이 합친 자료를 다시 얹음).
* 보이는 도엽 12 장 이하 = 다 · 13~30 = 계곡선만(등고선 자리에 · 그 밖 선 겹 빔) · 넘음 = 선 겹 숨김.
* 받은 조각은 기억(최근 도엽 `KEEP_SHEETS` 장) — 다시 오면 즉시 · 받는 동안 서클(`track`).
* 지형 받기와 따로 — 보기만 옮겨도 조각은 받음 · 지형(받은 범위)은 안 받음(596-4 그대로).
* GL 이면 등고선은 안 받음(줌별 벡터 조각 · 615) — 옛 캔버스만 · GL 켬끔이 바뀌면 등고 겹을 다시 얹게 알림.
* ========================================================================== */
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;
/** 마지막 보기 경위도 상자. */
bbox: string;
failed: string[];
/** 조각마다 받은 시간(ms · 마지막 20). */
ms: Array<[string, number]>;
};
}
export function createSheetTiles(opts: {
projectId: string;
track: <T>(job: Promise<T>) => Promise<T>;
/** 지금 켠 겹의 조각 이름. */
wanted: () => string[];
/** GL 바탕인가(참 = 등고선 조각 안 받음). */
gl: () => boolean;
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;
let glWas = opts.gl();
/** 바뀐 조각 이름 — 잠깐 모아 한 번에 알림(겹마다 합친 자료를 다시 얹는 일 줄임). */
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);
}
/** 모드에 따른 실제 조각 — 계곡선만이면 등고선 = 계곡선 · 그 밖 = 없음 · GL 등고 = 없음. */
const source = (name: string): string | null => {
if (name === "등고선" && opts.gl()) return null;
return 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) => {
if (glWas !== opts.gl()) {
glWas = !glWas;
changed(["등고선"]);
}
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,
bbox: lastBox,
failed: [...failed],
ms: [...ms],
}),
};
}