- 공용 부품 ui_template_gl_map(지도 세우기 · 자료 · 켬끔 · 색 다시 · 카메라) — D03 GL 껍데기를 올림 · D03 은 겹 꼴만(바뀜 없음) - TM 주인 따라가기(B04_PreProcess_UI_MapGl · logic/_MapGl_Frame) — 화면 가운데 1차 맞춤 닮음꼴 카메라 + 남는 늘림(웹 메르카토르 가로세로 0.43 %)은 지도 칸 CSS 행렬 · 칸 둘레 여유 8 px - B04 하단 지도 — 위성 조각(줌인 선명) · 백지도 · 혼합 · 도엽 · 지적 · 행정 · 등고선 겹 GL · 등고 글 · 배수유역 · 계획선 · 관 = 위 캔버스 - B05 배수유역도 — 위성 · 세부유역 채움(짝홀 다각형) · 분수령 · 도엽 GL · 번호 · 상류 세류 · 관 · 강도 = 위 캔버스 - B05 평면 노선 편집 — 바탕칠 · LAS · 도엽 등고선(간격 거름 · 1 m 선) · 하천(LAS 상자 자름) GL · 노선 둘레 띠 = 지도 칸 clip-path · 고른 등고선 · 글 · 노선 · 꼭짓점 = 위 캔버스 - 켬끔 칸(GPU · 세 화면 한 취향 aislo:pref:map.canvas) · WebGL 못 씀 · 잃음 = 옛 캔버스 + 알림 · 누름 · 끌기 판정 손 안 댐 - B04 층 단추 글 = MapLayers gisLayerLabel(지도 본체 700 줄) - 지킴 시험 test_589_2_gl_frame(카메라 대 캔버스 0.5 px · 칸 행렬 늘이기만 · 되돌림 1 mm · 상자 자르기 · 짝홀 다각형) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
267 lines
9.8 KiB
TypeScript
267 lines
9.8 KiB
TypeScript
/* =============================================================================
|
|
* B04_PreProcess_UI_MapGl_Frame.ts
|
|
* GL 바탕이 TM 주인 화면을 따라가는 셈(PLAN 589-2) — B04 하단 지도 · B05 평면 노선 편집 · 배수유역도.
|
|
* 카메라 = 위성 그림 행렬(589-1 `imageScreenMatrix`)과 같은 길을 화면 가운데에서 1차로 맞춘 웹 메르카토르
|
|
* 가운데 · 줌 · 돌림(MapLibre 세계 512 px · 돌림 = 동쪽이 화면에서 도는 각의 반대).
|
|
* TM 겹 → 경위도 = 고를 칸 `toNorm` 되돌림(고정 기울기 되풀이 · 격자가 거의 1차라 몇 번에 1 mm 안).
|
|
* 순수 셈 — 화면 · fetch 없음.
|
|
* ========================================================================== */
|
|
|
|
import type { VWorldMeta } from "../B04_PreProcess_Api_Fetch";
|
|
import type { Normalizer, PreparedLayer } from "../B04_PreProcess_UI_MapRender";
|
|
import { imageScreenMatrix, normOfLonLat } from "./B04_PreProcess_UI_MapRender_Prepare";
|
|
|
|
export interface GlCamera {
|
|
center: [number, number];
|
|
zoom: number;
|
|
bearing: number;
|
|
/**
|
|
* 지도 칸 CSS 행렬 `[a, b, c, d]`(칸 가운데 축) — MapLibre 는 닮음꼴(확대 · 돌림)만 그리므로 남는 늘림
|
|
* (웹 메르카토르 px 가로세로가 땅과 0.4 % 다름 · 1 − M/N)을 칸에 줌. 늘이기만(고윳값 ≥ 1) — 칸 둘레 여유로 빈틈 없음.
|
|
*/
|
|
css: [number, number, number, number];
|
|
}
|
|
|
|
const IMAGE = 2 ** 20;
|
|
const latOfMercator = (m: number): number => (Math.atan(Math.exp(m)) * 360) / Math.PI - 90;
|
|
const mercatorOf = (lat: number): number => Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360));
|
|
|
|
/** 지금 화면(정규화 → 화면 px · 돌림 포함)에 맞는 GL 카메라 — 못 셈이면 null. */
|
|
export function glCameraOf(
|
|
meta: VWorldMeta,
|
|
normalizer: Normalizer,
|
|
toScreen: (nx: number, ny: number) => [number, number],
|
|
width: number,
|
|
height: number,
|
|
): GlCamera | null {
|
|
// 가상 그림(한 변 IMAGE px) — 행렬 셈의 미분 간격(0.5 px)이 상자 백만 분의 일이 되게
|
|
const [a, b, c, d, e, f] = imageScreenMatrix(
|
|
meta,
|
|
normalizer,
|
|
IMAGE,
|
|
IMAGE,
|
|
toScreen,
|
|
width / 2,
|
|
height / 2,
|
|
);
|
|
const det = a * d - b * c;
|
|
const lonRange = meta.lon_max - meta.lon_min;
|
|
const mTop = mercatorOf(meta.lat_max);
|
|
const mSpan = mTop - mercatorOf(meta.lat_min);
|
|
if (!Number.isFinite(det) || Math.abs(det) < 1e-18 || !lonRange || !mSpan) return null;
|
|
const ex = width / 2 - e;
|
|
const ey = height / 2 - f;
|
|
const u = (d * ex - c * ey) / det / IMAGE;
|
|
const v = (a * ey - b * ex) / det / IMAGE;
|
|
// 화면 px / 세계 px(줌 0 = 512) — 열 = 세계 동 · 남
|
|
const ku = (lonRange / IMAGE / 360) * 512;
|
|
const kv = (mSpan / IMAGE / (2 * Math.PI)) * 512;
|
|
const m = [a / ku, b / ku, c / kv, d / kv];
|
|
// 극 분해 — 돌림 R · 대칭 늘림 P(작은 늘림 = 1 이 되게 줌을 고름)
|
|
const turn = Math.atan2(m[1] - m[2], m[0] + m[3]);
|
|
const cos = Math.cos(turn);
|
|
const sin = Math.sin(turn);
|
|
// P = Rᵀ M
|
|
const p = [
|
|
cos * m[0] + sin * m[1],
|
|
-sin * m[0] + cos * m[1],
|
|
cos * m[2] + sin * m[3],
|
|
-sin * m[2] + cos * m[3],
|
|
];
|
|
const trace = (p[0] + p[3]) / 2;
|
|
const least = trace - Math.hypot((p[0] - p[3]) / 2, (p[1] + p[2]) / 2);
|
|
if (!(least > 0)) return null;
|
|
// 칸 행렬 T = M Rᵀ / 작은 늘림 = R P Rᵀ / 작은 늘림
|
|
const css: [number, number, number, number] = [
|
|
(m[0] * cos - m[2] * sin) / least,
|
|
(m[1] * cos - m[3] * sin) / least,
|
|
(m[0] * sin + m[2] * cos) / least,
|
|
(m[1] * sin + m[3] * cos) / least,
|
|
];
|
|
return {
|
|
center: [meta.lon_min + u * lonRange, latOfMercator(mTop - v * mSpan)],
|
|
zoom: Math.log2(least),
|
|
bearing: -(turn * 180) / Math.PI,
|
|
css,
|
|
};
|
|
}
|
|
|
|
/** 정규화(TM 자리) → 경위도 — `normOfLonLat` 되돌림. 상자 기울기로 되풀이(1차에 가까워 다섯 번이면 넉넉). */
|
|
export function lonLatOfNormFn(
|
|
normalizer: Normalizer,
|
|
): (nx: number, ny: number) => [number, number] {
|
|
const { lonMin, latMin, lonRange, latRange } = normalizer;
|
|
const latMax = latMin + latRange;
|
|
// 상자 기울기(정규화 1 당 경위도) — 같은 네모 식이면 곧바로 맞음
|
|
const [x00, y00] = normOfLonLat(normalizer, lonMin, latMax);
|
|
const [x10, y10] = normOfLonLat(normalizer, lonMin + lonRange, latMax);
|
|
const [x01, y01] = normOfLonLat(normalizer, lonMin, latMin);
|
|
const ja = (x10 - x00) / lonRange;
|
|
const jb = (y10 - y00) / lonRange;
|
|
const jc = (x01 - x00) / -latRange;
|
|
const jd = (y01 - y00) / -latRange;
|
|
const det = ja * jd - jb * jc || 1;
|
|
return (nx, ny) => {
|
|
let lon = lonMin + nx * lonRange;
|
|
let lat = latMax - ny * latRange;
|
|
for (let step = 0; step < 5; step += 1) {
|
|
const [x, y] = normOfLonLat(normalizer, lon, lat);
|
|
const dx = nx - x;
|
|
const dy = ny - y;
|
|
lon += (jd * dx - jc * dy) / det;
|
|
lat += (ja * dy - jb * dx) / det;
|
|
if (Math.abs(dx) + Math.abs(dy) < 1e-12) break;
|
|
}
|
|
return [lon, lat];
|
|
};
|
|
}
|
|
|
|
type LonLat = [number, number];
|
|
type Box = readonly [minX: number, minY: number, maxX: number, maxY: number];
|
|
|
|
/** 선분 [p, q] 를 상자에 자름(리앙-바스키) — 밖이면 null. */
|
|
function clipSegment(
|
|
px: number,
|
|
py: number,
|
|
qx: number,
|
|
qy: number,
|
|
box: Box,
|
|
): [number, number, number, number] | null {
|
|
let t0 = 0;
|
|
let t1 = 1;
|
|
const dx = qx - px;
|
|
const dy = qy - py;
|
|
const edges: [number, number][] = [
|
|
[-dx, px - box[0]],
|
|
[dx, box[2] - px],
|
|
[-dy, py - box[1]],
|
|
[dy, box[3] - py],
|
|
];
|
|
for (const [p, q] of edges) {
|
|
if (p === 0) {
|
|
if (q < 0) return null;
|
|
continue;
|
|
}
|
|
const r = q / p;
|
|
if (p < 0) t0 = Math.max(t0, r);
|
|
else t1 = Math.min(t1, r);
|
|
if (t0 > t1) return null;
|
|
}
|
|
return [px + t0 * dx, py + t0 * dy, px + t1 * dx, py + t1 * dy];
|
|
}
|
|
|
|
/** 정규화 점렬 → 상자 안 조각들(상자 없으면 통째). */
|
|
function runsOf(coords: ArrayLike<number>, closed: boolean, box?: Box): number[][] {
|
|
const n = coords.length / 2;
|
|
const points: number[] = Array.from(coords);
|
|
if (closed && n > 2) points.push(coords[0], coords[1]);
|
|
if (!box) return [points];
|
|
const runs: number[][] = [];
|
|
let run: number[] = [];
|
|
for (let i = 0; i + 3 < points.length; i += 2) {
|
|
const cut = clipSegment(points[i], points[i + 1], points[i + 2], points[i + 3], box);
|
|
if (!cut) {
|
|
if (run.length >= 4) runs.push(run);
|
|
run = [];
|
|
continue;
|
|
}
|
|
const [ax, ay, bx, by] = cut;
|
|
const last = run.length;
|
|
if (!last || run[last - 2] !== ax || run[last - 1] !== ay) {
|
|
if (last >= 4) runs.push(run);
|
|
run = [ax, ay];
|
|
}
|
|
run.push(bx, by);
|
|
}
|
|
if (run.length >= 4) runs.push(run);
|
|
return runs;
|
|
}
|
|
|
|
/**
|
|
* 준비한 겹(정규화 · TM 자리) → GL 경위도 GeoJSON. 선 = LineString(닫힌 것은 첫 점 다시) · 점 = MultiPoint ·
|
|
* 성질 `level` = 표고(없으면 뺌). `box`(정규화) = 그 안만(B05 LAS 등고선이 있는 데까지 · 0-9 ⑮).
|
|
*/
|
|
export function preparedToGeoJson(
|
|
layer: PreparedLayer,
|
|
toLonLat: (nx: number, ny: number) => LonLat,
|
|
box?: Box,
|
|
): { type: "FeatureCollection"; features: unknown[] } {
|
|
const features: unknown[] = [];
|
|
for (const feature of layer.features) {
|
|
const properties = feature.labelValue === null ? {} : { level: feature.labelValue };
|
|
for (const part of feature.parts) {
|
|
if (feature.kind === "point") {
|
|
const coordinates: LonLat[] = [];
|
|
for (let i = 0; i + 1 < part.coords.length; i += 2) {
|
|
const x = part.coords[i];
|
|
const y = part.coords[i + 1];
|
|
if (box && (x < box[0] || x > box[2] || y < box[1] || y > box[3])) continue;
|
|
coordinates.push(toLonLat(x, y));
|
|
}
|
|
if (coordinates.length)
|
|
features.push({
|
|
type: "Feature",
|
|
properties,
|
|
geometry: { type: "MultiPoint", coordinates },
|
|
});
|
|
continue;
|
|
}
|
|
for (const run of runsOf(part.coords, part.closed, box)) {
|
|
if (run.length < 4) continue;
|
|
const coordinates: LonLat[] = [];
|
|
for (let i = 0; i < run.length; i += 2) coordinates.push(toLonLat(run[i], run[i + 1]));
|
|
features.push({
|
|
type: "Feature",
|
|
properties,
|
|
geometry: { type: "LineString", coordinates },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return { type: "FeatureCollection", features };
|
|
}
|
|
|
|
/** 점이 고리 안인가(짝홀). */
|
|
function inside(x: number, y: number, ring: ReadonlyArray<readonly [number, number]>): boolean {
|
|
let hit = false;
|
|
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
const [xi, yi] = ring[i];
|
|
const [xj, yj] = ring[j];
|
|
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit;
|
|
}
|
|
return hit;
|
|
}
|
|
|
|
/**
|
|
* 고리 목록(조각 · 구멍 섞임 · 캔버스 `evenodd`) → GeoJSON 다각형들 — 몇 겹 안에 드나(짝 = 바깥 · 홀 = 구멍)로
|
|
* 구멍을 제 바깥에 붙임. 같은 칠 = 캔버스 짝홀 채움.
|
|
*/
|
|
export function ringsToPolygons(
|
|
rings: ReadonlyArray<ReadonlyArray<readonly [number, number]>>,
|
|
): LonLat[][][] {
|
|
const usable = rings.filter((ring) => ring.length >= 3);
|
|
const depth = usable.map((ring, i) =>
|
|
usable.reduce(
|
|
(count, other, j) => count + (i !== j && inside(ring[0][0], ring[0][1], other) ? 1 : 0),
|
|
0,
|
|
),
|
|
);
|
|
const polygons: LonLat[][][] = [];
|
|
const owner = new Map<number, number>();
|
|
usable.forEach((ring, i) => {
|
|
if (depth[i] % 2) return;
|
|
owner.set(i, polygons.length);
|
|
polygons.push([ring.map(([x, y]) => [x, y] as LonLat)]);
|
|
});
|
|
usable.forEach((ring, i) => {
|
|
if (!(depth[i] % 2)) return;
|
|
// 제 바깥 = 저를 품은 고리 중 한 겹 얕은 것
|
|
const parent = usable.findIndex(
|
|
(other, j) => depth[j] === depth[i] - 1 && inside(ring[0][0], ring[0][1], other),
|
|
);
|
|
const at = owner.get(parent);
|
|
if (at !== undefined) polygons[at].push(ring.map(([x, y]) => [x, y] as LonLat));
|
|
});
|
|
return polygons;
|
|
}
|