feat(D03): 노선분석 GL 바탕 시험 — MapLibre 바탕 겹 여섯 · 위 캔버스 그대로 · 처음 = 끔 (PLAN 586-1 · 요구 445 B)
- 겹 단추줄 끝 [GL 바탕 시험] 켬끔(aislo:pref:d03.gl · 처음 = 지금 캔버스)
- 켬 = MapLibre(설치본 5.24 · BSD-3)가 위성 조각 · 급경사 그림 · 지적 · 등고선(주곡선 · 계곡선) · 도로 · 세류를 그림
· 받는 GeoJSON 그대로 · 등고 글 · 그 밖 겹 · 그은 선 · 재기 · 누름 = 캔버스 그대로
- 카메라 = D03 화면(가운데 · 확대 · 돌림)을 따라감 · 같은 화면에 바로 그림 · 캔버스 겹은 MapLibre 투영으로
- 위성 조각 대리 GET …/plan/tile/{z}/{x}/{y}(브이월드 Satellite z6~19 · 키 안 샘)
- 잼(headless 실 GPU · 검증_sub2_D · 겹 여섯): 줌아웃 화면 간격 중앙 20.9 → 4.2 ms · p90 29.2 → 12.5 · 끊김 5 → 0/30
· 끌기 20.8 → 4.2 ms · 끊김 2 → 0/40 · 그은 선 대 GL 바탕 0.02 px(돌림 30° 같음) · 재기 차 0 · 긋기 점 두 판 차 0.00001 m
- 줌인 = 위성 z19 조각으로 선명 · 급경사 그림 매끈 · 선 자리 두 판 같음
- 시험: test_586_tm_frame 조각 대리 범위 밖 400 · test_582 겹 수 낡은 셈(12 → 584 뒤 17) 고침
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -138,6 +138,8 @@ export async function fetchPlanSurface(projectId: string): Promise<PlanSurface |
|
||||
/** 지도 겹 그림(569-1) — 작업 좌표계 m 상자(칸 가장자리) · data URL(위 = 북) · 단계 색 · 넓이 비율. */
|
||||
export interface PlanRaster {
|
||||
box_m: [number, number, number, number];
|
||||
/** 경위도 네 귀퉁이(왼쪽 아래 · 오른쪽 아래 · 오른쪽 위 · 왼쪽 위) — GL 바탕(586-1). */
|
||||
box?: Array<[number, number]>;
|
||||
image: string;
|
||||
bands: Array<{ min_deg: number; max_deg: number; color: string; ratio: number }>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
- `GET /api/projects/{id}/plan/terrain/extent` — 받기 전 범위(상자 · 도엽 · 칸 수 · 받은 범위)
|
||||
- `POST …/plan/terrain` — 사업 전체 지형 한 벌 받기(뒤에서 · 누를 때만) · 끝나면 `surface_models` 행
|
||||
- `GET …/plan/progress` — 일의 진행률
|
||||
- `GET …/plan/tile/{z}/{x}/{y}` — 위성 조각 대리(586-1 GL 바탕 시험)
|
||||
- `GET …/plan/layers/sheet?key=` · `…/cadastral` · `…/raster?kind=slope|hillshade|landslide` ·
|
||||
`…/zones` · `…/soils` · `…/bands` · `…/lines?kind=` · `…/zero-order` ·
|
||||
`…/forest-roads`(GET · POST 올리기) · `…/landslide-zones` — 지도 겹
|
||||
@@ -349,3 +350,29 @@ async def plan_layer_landslide_zones(project_id: UUID) -> dict[str, Any]:
|
||||
cadastral = None
|
||||
value = await asyncio.to_thread(landslide_zone.zone_layer, root, cadastral, box, epsg)
|
||||
return {"status": "success", **value}
|
||||
|
||||
|
||||
@router.get("/{project_id}/plan/tile/{z}/{x}/{y}", response_model=None)
|
||||
async def plan_satellite_tile(project_id: UUID, z: int, x: int, y: int) -> Response:
|
||||
"""위성 조각(PLAN 586-1 GL 바탕 시험 · 브이월드 Satellite z6~19) — 키가 화면에 안 새게 대리."""
|
||||
import urllib.request
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_VWorld import VWORLD_API_KEY
|
||||
|
||||
_ = project_id
|
||||
if not (6 <= z <= 19 and 0 <= x < 2**z and 0 <= y < 2**z):
|
||||
raise HTTPException(status_code=400, detail="조각 번호가 범위를 벗어났습니다.")
|
||||
url = f"http://api.vworld.kr/req/wmts/1.0.0/{VWORLD_API_KEY}/Satellite/{z}/{y}/{x}.jpeg"
|
||||
|
||||
def fetch() -> bytes:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "Aislo/1.0"})
|
||||
with urllib.request.urlopen(request, timeout=8) as response:
|
||||
return response.read()
|
||||
|
||||
try:
|
||||
tile = await asyncio.to_thread(fetch)
|
||||
except Exception as error: # noqa: BLE001 — 한 조각 빔은 화면을 막을 일이 아님
|
||||
raise HTTPException(status_code=502, detail="위성 조각을 받지 못했습니다.") from error
|
||||
return Response(
|
||||
content=tile, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=86400"}
|
||||
)
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
|
||||
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
|
||||
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`).
|
||||
* GL 바탕 시험(586-1 · 끝 단추 · `aislo:pref:d03.gl` · 처음 = 끔) — 위성 · 급경사 · 지적 · 등고선 · 도로 · 세류를
|
||||
* MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스는 그 겹을 건너뜀(등고 글은 캔버스).
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
@@ -24,7 +26,7 @@ import { createIconButton } from "@ui/ui_template_button";
|
||||
import { createMapTools } from "@ui/ui_template_map_tools";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
||||
import { writeByKey } from "../A00_Common/b_page_state";
|
||||
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
fetchVWorldMeta,
|
||||
getVWorldMapUrl,
|
||||
@@ -72,10 +74,12 @@ import {
|
||||
type PlanLayerKey,
|
||||
} from "./D03_PlanAnalysis_UI_LayerDefs";
|
||||
import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend";
|
||||
import { createGlBase, GL_KEYS } from "./D03_PlanAnalysis_UI_MapGl";
|
||||
import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
|
||||
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
|
||||
const PREF_KEY = "aislo:pref:d03.layers";
|
||||
const GL_PREF_KEY = "aislo:pref:d03.gl";
|
||||
/** 등고선을 받아 올 그은 선 둘레(m). */
|
||||
const CONTOUR_PAD_M = 600;
|
||||
type DataKey = keyof Prepared;
|
||||
@@ -112,6 +116,9 @@ export function createPlanLayers(opts: {
|
||||
let uploading = false;
|
||||
let meta: VWorldMeta | null = null;
|
||||
let satellite: HTMLImageElement | null = null;
|
||||
const gl = createGlBase(projectId);
|
||||
let glOn = readByKey(GL_PREF_KEY) === "1";
|
||||
map.setGl(glOn ? gl : null);
|
||||
|
||||
const buttons = new Map<PlanLayerKey, HTMLButtonElement>();
|
||||
const groups = GROUPS.map((group) =>
|
||||
@@ -141,6 +148,21 @@ export function createPlanLayers(opts: {
|
||||
upload.dataset.layer = "forestRoadUpload";
|
||||
const roadGroup = groups.find((group) => group.includes(buttons.get("forestRoadFile")!))!;
|
||||
roadGroup.splice(roadGroup.indexOf(buttons.get("forestRoadFile")!) + 1, 0, upload);
|
||||
const glButton = createIconButton({
|
||||
icon: "gauge",
|
||||
title:
|
||||
"GL 바탕 시험(위성 조각 · 급경사 · 지적 · 등고선 · 도로 · 세류를 그래픽 카드로 · 끔 = 지금 그림)",
|
||||
size: 32,
|
||||
pressed: glOn,
|
||||
onClick: () => {
|
||||
glOn = !glOn;
|
||||
writeByKey(GL_PREF_KEY, glOn ? "1" : "0");
|
||||
glButton.setAttribute("aria-pressed", String(glOn));
|
||||
map.setGl(glOn ? gl : null);
|
||||
},
|
||||
});
|
||||
glButton.dataset.layer = "gl";
|
||||
groups.push([glButton]);
|
||||
const tools = createMapTools({ label: "지도 겹", groups });
|
||||
const bar = tools.root;
|
||||
bar.classList.add("d03-plan__layers");
|
||||
@@ -165,6 +187,7 @@ export function createPlanLayers(opts: {
|
||||
button.setAttribute("aria-label", title);
|
||||
}
|
||||
upload.disabled = !meta || uploading;
|
||||
for (const key of GL_KEYS) gl.setVisible(key, on[key]);
|
||||
const rows = meta ? legendRows(on, data) : [];
|
||||
legend.replaceChildren(...rows.map((row) => createLegend(row)));
|
||||
legend.hidden = !rows.length;
|
||||
@@ -280,6 +303,7 @@ export function createPlanLayers(opts: {
|
||||
bbox.join(","),
|
||||
);
|
||||
data.contour = indexLayerLod(prepare(collection, ["등고수치"]));
|
||||
gl.setData("contour", collection);
|
||||
} else if (key === "stream") {
|
||||
const collection = await fetchCachedSheetLayer<GeoJsonCollection>(
|
||||
projectId,
|
||||
@@ -289,8 +313,11 @@ export function createPlanLayers(opts: {
|
||||
prepare(pick(collection, (props) => props["구분"] === "세류")),
|
||||
prepare(pick(collection, (props) => props["구분"] !== "세류")),
|
||||
];
|
||||
gl.setData("stream", collection);
|
||||
} else if (key === "cadastral") {
|
||||
data.cadastral = prepare(await fetchPlanCadastral(projectId));
|
||||
const collection = await fetchPlanCadastral(projectId);
|
||||
data.cadastral = prepare(collection);
|
||||
gl.setData("cadastral", collection);
|
||||
} else if (key === "road" || key === "forestRoad") {
|
||||
const collection = await fetchPlanSheetLayer(projectId, "도로중심선");
|
||||
data.road = {
|
||||
@@ -299,6 +326,7 @@ export function createPlanLayers(opts: {
|
||||
pick(collection, (props) => FOREST_ROAD_KINDS.has(String(props["도로구분"]))),
|
||||
),
|
||||
};
|
||||
gl.setData("road", collection);
|
||||
} else if (key === "forestRoadFile") {
|
||||
const info = await fetchPlanForestRoads(projectId);
|
||||
data.forestRoadFile = { layer: prepare(info.collection), info };
|
||||
@@ -310,6 +338,9 @@ export function createPlanLayers(opts: {
|
||||
} else if (key === "slope" || key === "hillshade" || key === "landslide") {
|
||||
const raster = await fetchPlanRaster(projectId, key);
|
||||
data[key] = { raster, image: await loadImage(raster.image) };
|
||||
const box = raster.box; // 경위도 귀퉁이(왼쪽 아래부터 돎)
|
||||
if (key === "slope" && box?.length === 4)
|
||||
gl.setData("slope", { url: raster.image, corners: [box[3], box[2], box[1], box[0]] });
|
||||
} else if (key === "water") {
|
||||
const parts = await Promise.all(
|
||||
WATER_KEYS.map((name) => fetchPlanSheetLayer(projectId, name)),
|
||||
@@ -397,7 +428,7 @@ export function createPlanLayers(opts: {
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
if (!on.slope || !data.slope) return;
|
||||
if (!on.slope || !data.slope || glOn) return;
|
||||
context.globalAlpha = 0.55;
|
||||
drawRaster(context, map, data.slope);
|
||||
},
|
||||
@@ -451,7 +482,7 @@ export function createPlanLayers(opts: {
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
if (!on.cadastral || !data.cadastral) return;
|
||||
if (!on.cadastral || !data.cadastral || glOn) return;
|
||||
context.globalAlpha = 0.8;
|
||||
stroke(context, map, data.cadastral, themeColor("--draw-map-parcel", "#f0abfc"), 0.8);
|
||||
},
|
||||
@@ -481,9 +512,13 @@ export function createPlanLayers(opts: {
|
||||
const contour = on.contour && data.contour;
|
||||
if (!contour) return;
|
||||
const color = themeColor("--draw-map-contour", "#ffd68c");
|
||||
context.globalAlpha = 0.7;
|
||||
stroke(context, map, contour, color, 0.8, pickLevelStep(contour, map.view, 5));
|
||||
stroke(context, map, contour, color, 1.6, 25); // 계곡선
|
||||
const step = pickLevelStep(contour, map.view, 5);
|
||||
if (glOn) gl.setContourStep(step); // 선 = GL · 글 = 캔버스
|
||||
if (!glOn) {
|
||||
context.globalAlpha = 0.7;
|
||||
stroke(context, map, contour, color, 0.8, step);
|
||||
stroke(context, map, contour, color, 1.6, 25); // 계곡선
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
context.font = "10px system-ui, sans-serif";
|
||||
context.textAlign = "center";
|
||||
@@ -503,7 +538,7 @@ export function createPlanLayers(opts: {
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
if (!on.road || !data.road) return;
|
||||
if (!on.road || !data.road || glOn) return;
|
||||
stroke(context, map, data.road.all, "rgba(0, 0, 0, 0.55)", 3.2);
|
||||
stroke(context, map, data.road.all, themeColor("--draw-map-road", "#f8fafc"), 1.6);
|
||||
},
|
||||
@@ -530,7 +565,7 @@ export function createPlanLayers(opts: {
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
if (!on.stream || !data.stream) return;
|
||||
if (!on.stream || !data.stream || glOn) return;
|
||||
const color = themeColor("--draw-map-stream", "#38bdf8");
|
||||
stroke(context, map, data.stream[0], color, 1);
|
||||
stroke(context, map, data.stream[1], color, 2.4);
|
||||
@@ -608,6 +643,7 @@ export function createPlanLayers(opts: {
|
||||
if (changed) {
|
||||
satellite = null;
|
||||
for (const key of Object.keys(data)) delete data[key as DataKey];
|
||||
for (const key of GL_KEYS) if (key !== "satellite") gl.setData(key, null);
|
||||
}
|
||||
map.update({ meta });
|
||||
showSatellite();
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* 캔버스 두 장(582) — 아래 = 위성 · 바탕 겹(`base`) · 위 = 그은 선 · 재기 · 누름 받음.
|
||||
* 마우스 움직임 = 위만 다시 그림 · 화면이 바뀌면(옮김 · 확대 · 돌림 · 겹) 둘 다 · 한 화면 한 번(rAF).
|
||||
* TM 겹(그은 선 · 재기 · 그림 겹)은 경위도로 바꿔 바탕과 같은 자리(586-0 · `localTmFrame`) — 좌표계를 모르면 옛 꼴.
|
||||
* GL 바탕 시험(586-1 · `setGl`) — 맨 아래 MapLibre 가 이 화면을 따라감 · 캔버스 겹은 그 투영으로 그림.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools";
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
import { bindRouteEditNavigation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Input";
|
||||
import { createMapRotation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Rotate";
|
||||
import { fitCanvas, screenToMetric } from "../B05_Profile/B05_Profile_UI_RouteEdit_View";
|
||||
import type { GlBase } from "./D03_PlanAnalysis_UI_MapGl";
|
||||
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import { localTmFrame, tmParams } from "./logic/D03_PlanAnalysis_Tm";
|
||||
|
||||
@@ -83,6 +85,12 @@ export interface PlanMapLayer {
|
||||
leave?: () => void;
|
||||
}
|
||||
|
||||
/** 경위도 ↔ 그림 px(돌리기 전). */
|
||||
interface LonLatFrame {
|
||||
project: (lon: number, lat: number) => [number, number];
|
||||
unproject: (px: number, py: number) => [number, number];
|
||||
}
|
||||
|
||||
export interface PlanMapEvents {
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
@@ -107,6 +115,8 @@ export interface PlanMap {
|
||||
redraw: () => void;
|
||||
/** 바탕 겹까지 다시 그림(겹 켬끔 · 받음). */
|
||||
redrawBase: () => void;
|
||||
/** GL 바탕 켬(MapLibre 를 맨 아래에) · null = 끔(지금 캔버스). */
|
||||
setGl: (gl: GlBase | null) => void;
|
||||
/** 맞출 점이 들어오게 다시 맞춤. */
|
||||
fit: () => void;
|
||||
/** 검증 창구 — 작업 좌표계 점 → 화면에 보이는 px(돌린 뒤 · 그리기 전이면 null). */
|
||||
@@ -198,6 +208,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
let layers: PlanMapLayer[] = [];
|
||||
let meta: VWorldMeta | null = null;
|
||||
let fitted = false;
|
||||
let gl: GlBase | null = null;
|
||||
let view: ViewState = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
@@ -212,6 +223,71 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
onChange: () => draw(),
|
||||
});
|
||||
|
||||
/** 경위도 ↔ 그림 px(돌리기 전) — 캔버스 = 위성 직사각형에 폄. */
|
||||
const canvasLonLat = (meta: VWorldMeta): LonLatFrame => {
|
||||
const normalizer = createNormalizer(meta);
|
||||
const [x0, y0] = normalizedToScreen(view, 0, 0);
|
||||
const [x1, y1] = normalizedToScreen(view, 1, 1);
|
||||
return {
|
||||
project: (lon, lat) => lonLatToScreen(normalizer, view, lon, lat),
|
||||
unproject: (px, py) => [
|
||||
normalizer.lonMin + ((px - x0) / (x1 - x0)) * normalizer.lonRange,
|
||||
normalizer.latMin + (1 - (py - y0) / (y1 - y0)) * normalizer.latRange,
|
||||
],
|
||||
};
|
||||
};
|
||||
/** 지금 바탕의 경위도 ↔ 그림 px — GL 이면 MapLibre 투영(돌림 되돌림). */
|
||||
const lonLat = (meta: VWorldMeta): LonLatFrame => {
|
||||
const glMap = gl?.map();
|
||||
if (!glMap) return canvasLonLat(meta);
|
||||
return {
|
||||
project: (lon, lat) => {
|
||||
const point = glMap.project([lon, lat]);
|
||||
return rotation.unrotate(point.x, point.y);
|
||||
},
|
||||
unproject: (px, py) => {
|
||||
const at = glMap.unproject(rotation.rerotate(px, py));
|
||||
return [at.lng, at.lat];
|
||||
},
|
||||
};
|
||||
};
|
||||
/** GL 바탕일 때 캔버스 경위도 겹이 쓸 틀 — MapLibre 투영을 화면 가운데에서 1차로 맞춤. */
|
||||
const glView = (meta: VWorldMeta): ViewState => {
|
||||
const frame = lonLat(meta);
|
||||
const normalizer = createNormalizer(meta);
|
||||
const [lon, lat] = frame.unproject(view.width / 2, view.height / 2);
|
||||
const step = 1e-4;
|
||||
const [cx, cy] = frame.project(lon, lat);
|
||||
const [ex] = frame.project(lon + step, lat);
|
||||
const [wx] = frame.project(lon - step, lat);
|
||||
const [, ny] = frame.project(lon, lat + step);
|
||||
const [, sy] = frame.project(lon, lat - step);
|
||||
const ax = ((ex - wx) / (2 * step)) * normalizer.lonRange;
|
||||
const ay = -((ny - sy) / (2 * step)) * normalizer.latRange;
|
||||
const u = (lon - normalizer.lonMin) / normalizer.lonRange;
|
||||
const v = 1 - (lat - normalizer.latMin) / normalizer.latRange;
|
||||
return {
|
||||
...view,
|
||||
scale: 1,
|
||||
offsetX: cx - ax * u,
|
||||
offsetY: cy - ay * v,
|
||||
mapRect: { x: 0, y: 0, width: ax, height: ay },
|
||||
};
|
||||
};
|
||||
/** GL 카메라를 지금 화면(가운데 · 확대 · 돌림)에 맞춤. */
|
||||
function glCamera(): void {
|
||||
if (!gl?.map() || !scene.meta || !view.width) return;
|
||||
const frame = canvasLonLat(scene.meta);
|
||||
const [x0] = normalizedToScreen(view, 0, 0);
|
||||
const [x1] = normalizedToScreen(view, 1, 1);
|
||||
const pxPerDegree = (x1 - x0) / (scene.meta.lon_max - scene.meta.lon_min || 1);
|
||||
gl.camera(
|
||||
frame.unproject(view.width / 2, view.height / 2),
|
||||
Math.log2((pxPerDegree * 360) / 512),
|
||||
-(rotation.radians() * 180) / Math.PI,
|
||||
);
|
||||
}
|
||||
|
||||
/** TM ↔ 그림 px — 경위도 바탕에 맞춤(586-0) · 가짜 도엽 · 모르는 좌표계 = 옛 「같은 직사각형」 꼴. */
|
||||
const metricFrame = (): Pick<ScreenMap, "toScreen" | "toMetric" | "pxPerMeter"> => {
|
||||
const params = tmParams(scene.epsg);
|
||||
@@ -223,19 +299,8 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
pxPerMeter: projector.pxPerMeter,
|
||||
};
|
||||
}
|
||||
const normalizer = createNormalizer(scene.meta);
|
||||
const [x0, y0] = normalizedToScreen(view, 0, 0);
|
||||
const [x1, y1] = normalizedToScreen(view, 1, 1);
|
||||
const frame = localTmFrame(
|
||||
params,
|
||||
(lon, lat) => lonLatToScreen(normalizer, view, lon, lat),
|
||||
(px, py) => [
|
||||
normalizer.lonMin + ((px - x0) / (x1 - x0)) * normalizer.lonRange,
|
||||
normalizer.latMin + (1 - (py - y0) / (y1 - y0)) * normalizer.latRange,
|
||||
],
|
||||
view.width / 2,
|
||||
view.height / 2,
|
||||
);
|
||||
const { project, unproject } = lonLat(scene.meta);
|
||||
const frame = localTmFrame(params, project, unproject, view.width / 2, view.height / 2);
|
||||
return {
|
||||
toScreen: (point) => frame.toScreen(point[0], point[1]),
|
||||
toMetric: (px, py) => frame.toMetric(px, py),
|
||||
@@ -246,7 +311,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
const screenMap = (): ScreenMap => {
|
||||
return {
|
||||
...metricFrame(),
|
||||
view,
|
||||
view: gl?.map() && scene.meta ? glView(scene.meta) : view,
|
||||
...marks,
|
||||
uprightRad: rotation.uprightRad(),
|
||||
upright: (ctx, x, y, paint) => {
|
||||
@@ -289,7 +354,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
ctx.translate(view.width / 2, view.height / 2);
|
||||
ctx.rotate(rotation.radians());
|
||||
ctx.translate(-view.width / 2, -view.height / 2);
|
||||
if (base && scene.image?.complete && scene.image.naturalWidth && scene.meta) {
|
||||
if (base && !gl && scene.image?.complete && scene.image.naturalWidth && scene.meta) {
|
||||
const [x0, y0] = normalizedToScreen(view, 0, 0);
|
||||
const [x1, y1] = normalizedToScreen(view, 1, 1);
|
||||
ctx.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0);
|
||||
@@ -311,6 +376,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
function flush(): void {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
if (baseDirty) glCamera();
|
||||
if (baseDirty) paint(baseContext, true);
|
||||
if (topDirty) paint(context, false);
|
||||
baseDirty = topDirty = false;
|
||||
@@ -330,6 +396,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
if (!width || !height) return;
|
||||
view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) };
|
||||
if (!fitted) refit();
|
||||
gl?.resize();
|
||||
// 크기를 바꾸면 캔버스가 비므로 같은 화면에 바로 그림(빈 화면 한 번 없음)
|
||||
baseDirty = topDirty = true;
|
||||
flush();
|
||||
@@ -407,6 +474,14 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
},
|
||||
redraw: drawTop,
|
||||
redrawBase: draw,
|
||||
setGl(next) {
|
||||
if (next === gl) return;
|
||||
gl?.enable(null);
|
||||
gl = next;
|
||||
gl?.enable(root);
|
||||
gl?.resize();
|
||||
draw();
|
||||
},
|
||||
fit() {
|
||||
refit();
|
||||
draw();
|
||||
@@ -416,12 +491,11 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
const [x, y] = screenMap().toScreen(point);
|
||||
return rotation.rerotate(x, y);
|
||||
},
|
||||
screenOfLonLat: (lon, lat) => {
|
||||
if (!scene.meta) return null;
|
||||
return rotation.rerotate(...lonLatToScreen(createNormalizer(scene.meta), view, lon, lat));
|
||||
},
|
||||
screenOfLonLat: (lon, lat) =>
|
||||
scene.meta ? rotation.rerotate(...lonLat(scene.meta).project(lon, lat)) : null,
|
||||
rotationDeg: () => (rotation.radians() * 180) / Math.PI,
|
||||
dispose: () => {
|
||||
gl?.enable(null);
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(frame);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_MapGl.ts
|
||||
* D03 GL 바탕 시험(PLAN 586-1 · 요구 445 B) — 바탕 겹 여섯을 MapLibre(WebGL · BSD-3)로:
|
||||
* 위성 조각(서버 대리 z6~19) · 급경사 그림 · 지적 · 등고선(주곡선 · 계곡선 25 m) · 도로 · 세류 · 하천.
|
||||
* 그 밖 겹 · 등고 글 · 그은 선 · 재기 · 누름 = 지금 캔버스 그대로(위에 겹침).
|
||||
* 카메라 = D03 화면(`ViewState` · 돌림)을 따라감 · 조작은 안 받음(누름은 위 캔버스).
|
||||
* 자료 = 지금 받는 GeoJSON 그대로(조각내기는 MapLibre 일꾼 스레드) · 지도가 없어도 자료는 담아 둠.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
Map as MapLibreMap,
|
||||
type FilterSpecification,
|
||||
type LayerSpecification,
|
||||
type SourceSpecification,
|
||||
} from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
|
||||
/** GL 로 옮긴 겹. */
|
||||
export type GlLayerKey = "satellite" | "slope" | "cadastral" | "contour" | "road" | "stream";
|
||||
export const GL_KEYS: readonly GlLayerKey[] = [
|
||||
"satellite",
|
||||
"slope",
|
||||
"cadastral",
|
||||
"contour",
|
||||
"road",
|
||||
"stream",
|
||||
];
|
||||
|
||||
/** 급경사 그림 — 주소 + 네 귀퉁이 경위도(왼쪽 위 · 오른쪽 위 · 오른쪽 아래 · 왼쪽 아래). */
|
||||
export interface GlImage {
|
||||
url: string;
|
||||
corners: [[number, number], [number, number], [number, number], [number, number]];
|
||||
}
|
||||
|
||||
export interface GlBase {
|
||||
/** 자료 담기(지도 없어도 · 켤 때 올림) — null = 비움. */
|
||||
setData: (
|
||||
key: Exclude<GlLayerKey, "satellite">,
|
||||
data: GeoJsonCollection | GlImage | null,
|
||||
) => void;
|
||||
setVisible: (key: GlLayerKey, on: boolean) => void;
|
||||
/** 등고선 주곡선 간격(m) — 캔버스와 같은 「화면 안 가닥 수」 규칙. */
|
||||
setContourStep: (step: number) => void;
|
||||
/** 켬 = 지도를 `parent` 맨 앞에 만듦 · 끔 = 버림. */
|
||||
enable: (parent: HTMLElement | null) => void;
|
||||
map: () => MapLibreMap | null;
|
||||
/** 화면을 따라감(경위도 가운데 · 줌 · 돌림 도) — 같은 화면에 바로 그림. */
|
||||
camera: (center: [number, number], zoom: number, bearingDeg: number) => void;
|
||||
resize: () => void;
|
||||
}
|
||||
|
||||
const HALO = "rgba(0, 0, 0, 0.55)";
|
||||
|
||||
const LEVEL = ["to-number", ["get", "등고수치"], 0];
|
||||
/** 주곡선 = 간격 배수(계곡선도 — 캔버스처럼 계곡선은 두 번 겹쳐 짙음). */
|
||||
const minorFilter = (step: number): FilterSpecification =>
|
||||
["==", ["%", LEVEL, step], 0] as unknown as FilterSpecification;
|
||||
|
||||
/** 겹 차례(아래 → 위)와 꼴 — 캔버스 `D03_PlanAnalysis_UI_Layers` 의 색 · 굵기 · 투명도 그대로. */
|
||||
function layerSpecs(step: number): Array<LayerSpecification & { key: GlLayerKey }> {
|
||||
const contour = themeColor("--draw-map-contour", "#ffd68c");
|
||||
const road = themeColor("--draw-map-road", "#f8fafc");
|
||||
const stream = themeColor("--draw-map-stream", "#38bdf8");
|
||||
const parcel = themeColor("--draw-map-parcel", "#f0abfc");
|
||||
const round = { "line-join": "round", "line-cap": "round" } as const;
|
||||
return [
|
||||
{ key: "satellite", id: "satellite", type: "raster", source: "satellite" },
|
||||
{
|
||||
key: "slope",
|
||||
id: "slope",
|
||||
type: "raster",
|
||||
source: "slope",
|
||||
paint: { "raster-opacity": 0.55, "raster-fade-duration": 0 },
|
||||
},
|
||||
{
|
||||
key: "cadastral",
|
||||
id: "cadastral",
|
||||
type: "line",
|
||||
source: "cadastral",
|
||||
layout: round,
|
||||
paint: { "line-color": parcel, "line-width": 0.8, "line-opacity": 0.8 },
|
||||
},
|
||||
{
|
||||
key: "contour",
|
||||
id: "contour-minor",
|
||||
type: "line",
|
||||
source: "contour",
|
||||
filter: minorFilter(step),
|
||||
layout: round,
|
||||
paint: { "line-color": contour, "line-width": 0.8, "line-opacity": 0.7 },
|
||||
},
|
||||
{
|
||||
key: "contour",
|
||||
id: "contour-index",
|
||||
type: "line",
|
||||
source: "contour",
|
||||
filter: ["==", ["%", LEVEL, 25], 0],
|
||||
layout: round,
|
||||
paint: { "line-color": contour, "line-width": 1.6, "line-opacity": 0.7 },
|
||||
},
|
||||
{
|
||||
key: "road",
|
||||
id: "road-halo",
|
||||
type: "line",
|
||||
source: "road",
|
||||
layout: round,
|
||||
paint: { "line-color": HALO, "line-width": 3.2 },
|
||||
},
|
||||
{
|
||||
key: "road",
|
||||
id: "road",
|
||||
type: "line",
|
||||
source: "road",
|
||||
layout: round,
|
||||
paint: { "line-color": road, "line-width": 1.6 },
|
||||
},
|
||||
{
|
||||
key: "stream",
|
||||
id: "stream",
|
||||
type: "line",
|
||||
source: "stream",
|
||||
layout: round,
|
||||
paint: {
|
||||
"line-color": stream,
|
||||
"line-width": ["case", ["==", ["get", "구분"], "세류"], 1, 2.4],
|
||||
},
|
||||
},
|
||||
] as Array<LayerSpecification & { key: GlLayerKey }>;
|
||||
}
|
||||
|
||||
/** 겹 색 지문 — 밝음 · 어두움이 바뀌면 색을 다시. */
|
||||
const themeKey = (): string =>
|
||||
["--draw-map-contour", "--draw-map-road", "--draw-map-stream", "--draw-map-parcel"]
|
||||
.map((name) => themeColor(name, ""))
|
||||
.join();
|
||||
|
||||
export function createGlBase(projectId: string): GlBase {
|
||||
const data = new Map<GlLayerKey, GeoJsonCollection | GlImage | null>();
|
||||
const visible = new Map<GlLayerKey, boolean>();
|
||||
let step = 5;
|
||||
let theme = "";
|
||||
let map: MapLibreMap | null = null;
|
||||
/** 첫 꼴이 섬(`isStyleLoaded` 는 조각 받는 동안 거짓이라 안 씀). */
|
||||
let ready = false;
|
||||
const applied = new Map<GlLayerKey, unknown>();
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
const source = (key: GlLayerKey): SourceSpecification | null => {
|
||||
if (key === "satellite")
|
||||
return {
|
||||
type: "raster",
|
||||
tiles: [`${location.origin}${API_BASE_URL}/projects/${projectId}/plan/tile/{z}/{x}/{y}`],
|
||||
tileSize: 256,
|
||||
minzoom: 6,
|
||||
maxzoom: 19,
|
||||
};
|
||||
const got = data.get(key);
|
||||
if (!got) return null;
|
||||
if ("url" in got) return { type: "image", url: got.url, coordinates: got.corners };
|
||||
return { type: "geojson", data: got as never, tolerance: 0.375 };
|
||||
};
|
||||
|
||||
/** 자료 · 켬끔 · 색을 지도에 맞춤(지도가 다 서면) — 자료는 바뀐 겹만 다시 올림. */
|
||||
function apply(): void {
|
||||
if (!map || !ready) return;
|
||||
const now = themeKey();
|
||||
const specs = layerSpecs(step);
|
||||
const drop = (key?: GlLayerKey): void => {
|
||||
for (const spec of specs)
|
||||
if ((!key || spec.key === key) && map!.getLayer(spec.id)) map!.removeLayer(spec.id);
|
||||
};
|
||||
if (now !== theme) {
|
||||
theme = now;
|
||||
drop(); // 밝음 · 어두움 = 색 다시
|
||||
}
|
||||
for (const key of GL_KEYS) {
|
||||
const got = key === "satellite" ? key : (data.get(key) ?? null);
|
||||
if (applied.get(key) === got) continue;
|
||||
applied.set(key, got);
|
||||
if (map.getSource(key)) {
|
||||
drop(key);
|
||||
map.removeSource(key);
|
||||
}
|
||||
const want = source(key);
|
||||
if (want) map.addSource(key, want);
|
||||
}
|
||||
specs.forEach(({ key, ...spec }, index) => {
|
||||
if (!map!.getSource(key)) return;
|
||||
if (!map!.getLayer(spec.id)) {
|
||||
// 늦게 온 겹도 제 차례(위 겹 밑)에
|
||||
const above = specs.slice(index + 1).find((next) => map!.getLayer(next.id));
|
||||
map!.addLayer(spec as LayerSpecification, above?.id);
|
||||
}
|
||||
map!.setLayoutProperty(spec.id, "visibility", visible.get(key) ? "visible" : "none");
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
setData(key, next) {
|
||||
if (data.get(key) === next) return;
|
||||
data.set(key, next);
|
||||
apply();
|
||||
},
|
||||
setVisible(key, on) {
|
||||
if (visible.get(key) === on) return;
|
||||
visible.set(key, on);
|
||||
apply();
|
||||
},
|
||||
setContourStep(next) {
|
||||
if (next === step) return;
|
||||
step = next;
|
||||
if (map?.getLayer("contour-minor")) map.setFilter("contour-minor", minorFilter(step));
|
||||
},
|
||||
enable(parent) {
|
||||
if (!parent === !map) return;
|
||||
if (!parent) {
|
||||
map?.remove();
|
||||
container?.remove();
|
||||
map = container = null;
|
||||
ready = false;
|
||||
theme = "";
|
||||
applied.clear();
|
||||
return;
|
||||
}
|
||||
container = document.createElement("div");
|
||||
container.className = "d03-plan__gl";
|
||||
parent.prepend(container);
|
||||
map = new MapLibreMap({
|
||||
container,
|
||||
style: { version: 8, sources: {}, layers: [] },
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
fadeDuration: 0,
|
||||
maxZoom: 24,
|
||||
renderWorldCopies: false,
|
||||
});
|
||||
map.on("load", () => {
|
||||
ready = true;
|
||||
apply();
|
||||
});
|
||||
},
|
||||
map: () => map,
|
||||
camera(center, zoom, bearingDeg) {
|
||||
if (!map) return;
|
||||
if (ready && themeKey() !== theme) apply();
|
||||
map.jumpTo({ center, zoom, bearing: bearingDeg });
|
||||
if (ready) map.redraw(); // 캔버스와 같은 화면(다음 화면으로 미루지 않음)
|
||||
},
|
||||
resize: () => map?.resize(),
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,13 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 586-1 GL 바탕 시험 — 바탕 캔버스 밑 MapLibre · 누름은 위 캔버스가 받음 */
|
||||
.d03-plan__map .d03-plan__gl {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.d03-plan__map canvas:not(.d03-plan__base) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ def test_D03_만_켬():
|
||||
layers = _read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
|
||||
assert "indexLayerLod(prepare(collection" in layers
|
||||
# 581-1 합침 — 겹 모두 바탕 캔버스
|
||||
assert layers.count("base: true") == layers.count("draw: (context, map)") == 12
|
||||
assert layers.count("base: true") == layers.count("draw: (context, map)") >= 12
|
||||
plan_map = _read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
|
||||
assert "requestAnimationFrame(flush)" in plan_map
|
||||
assert "drawTop();" in plan_map
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
- 모르는 좌표계(5174 Bessel) = null(지금 꼴로 그림)
|
||||
- 화면 TM 틀(가운데 1차 맞춤) = 정식 변환 자리와 화면 안 0.5 px 안 · 되돌림 = 1 mm 안
|
||||
- 옛 「같은 직사각형」 꼴은 D 프로젝트 귀퉁이에서 수십 m 어긋남(고친 까닭)
|
||||
- 586-1 위성 조각 대리 = 범위 밖 400
|
||||
|
||||
Node 헬퍼(`helper_586_tm_frame.cjs`)가 진짜 TS 를 돌린다.
|
||||
"""
|
||||
@@ -118,3 +119,18 @@ def test_옛_같은_직사각형_꼴은_귀퉁이에서_어긋남():
|
||||
dx = ((lon - META["lon_min"]) / (META["lon_max"] - META["lon_min"]) - 1) * w
|
||||
dy = ((META["lat_max"] - lat) / (META["lat_max"] - META["lat_min"])) * h
|
||||
assert math.hypot(dx, dy) > 50
|
||||
|
||||
|
||||
def test_위성_조각_대리는_범위_밖을_막음():
|
||||
"""586-1 GL 바탕 위성 조각 — z6~19 · 칸 번호 범위 밖 = 400(브이월드에 안 물음)."""
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from D03_PlanAnalysis.D03_PlanAnalysis_Router import plan_satellite_tile
|
||||
|
||||
for z, x, y in ((5, 0, 0), (20, 0, 0), (14, 2**14, 0), (14, 0, -1)):
|
||||
with pytest.raises(HTTPException) as error:
|
||||
asyncio.run(plan_satellite_tile(uuid.uuid4(), z, x, y))
|
||||
assert error.value.status_code == 400
|
||||
|
||||
Reference in New Issue
Block a user