perf(D03): 노선분석 평면 두 장 캔버스 · 한 화면 한 번 그리기 · 등고선 줌 단계 색인 · 글 폭 기억 — 그림 그대로 (PLAN 582-0 · 582-1 · 요구 440-9 · 441)

- D03 평면 = 바탕(위성 · 바탕 겹 다섯) / 위(그은 선 · 재기) 두 장 · 마우스 움직임 · 누름 = 위만 · 화면 바뀜 = 둘 다 · rAF 한 화면 한 번
- PlanMap.redraw = 위 겹만 · redrawBase = 바탕까지(겹 켬끔 · 받음)
- 공용 그리기에 선택 칸만 더함 — 조각 색인(part.lod · indexLayerLod 를 부른 레이어만) · 글 폭 기억(widths 인자) · B04 · B05 는 안 켬
- 한 판 한 번 긋기(d)는 뺌 — 반투명 선 겹침 색이 바뀌고(픽셀 14만 차) 래스터가 더 느림(36 → 60 ms)
- 지킴 시험 test_582_contour_lod — 색인 그린 명령 = 지금 명령(27 줌 · 자리 · 닫힌 고리 · 짧은 조각) · 글 폭 기억 같은 글

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 14:41:59 +09:00
co-authored by Claude Opus 5.5
parent c4b37023c7
commit 5e906ceb44
7 changed files with 420 additions and 29 deletions
+24 -3
View File
@@ -1,5 +1,6 @@
import { themeColor } from "@ui/ui_template_palette";
import type { VWorldMeta } from "./B04_PreProcess_Api_Fetch";
import { pickLodList } from "./logic/B04_PreProcess_UI_MapRender_Prepare";
// 2D 지도 벡터 레이어 렌더 엔진.
// GeoJSON 좌표를 로드 시 1회만 정규화 맵 좌표(0~1)로 사전 투영해 두고,
@@ -46,6 +47,14 @@ export type PreparedPart = {
coords: Float64Array;
closed: boolean;
weights: Float64Array | null;
/** 줌 단계 색인(582 · `indexLayerLod` 가 붙임 · 없으면 정점 전부 훑음) — 그리는 정점은 같음. */
lod?: PartLod;
};
/** 단마다 「무게 ≥ 그 단」 정점 번호(원래 차례). thresholds 는 큰 → 작은 차례. */
export type PartLod = {
thresholds: number[];
lists: Uint32Array[];
};
/** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */
@@ -375,6 +384,9 @@ function drawLineParts(
const coords = part.coords;
if (coords.length < 4) continue;
const weights = part.weights;
// 줌 단계 색인(582) = 「무게 ≥ 그 단」 정점만 훑음 — 아래 거름이 그대로라 그리는 정점 같음.
const list = part.lod ? pickLodList(part.lod, tolerance) : null;
const count = list ? list.length : coords.length / 2;
// 현재 줌에서 화면 오차 LOD_PX 미만인 정점만 생략 (끝점은 weight=∞라 항상 유지).
// 정점 사이 보간은 하지 않는다 — 원본 데이터의 형상 그대로 표시 (2026-07-28 사용자 지시).
context.beginPath();
@@ -388,8 +400,10 @@ function drawLineParts(
let prevX = 0;
let prevY = 0;
let prevSide = 0;
for (let i = 0; i < coords.length; i += 2) {
if (weights && weights[i / 2] < tolerance) continue;
for (let k = 0; k < count; k += 1) {
const v = list ? list[k] : k;
if (weights && weights[v] < tolerance) continue;
const i = v * 2;
const x = coords[i] * affine.ax + affine.bx;
const y = coords[i + 1] * affine.ay + affine.by;
const side = sideOf(x, y);
@@ -584,6 +598,8 @@ export function drawPreparedLabels(
uprightRad = 0,
/** 글자 테두리 색 — 지도 바탕색과 같아야 글자가 선을 끊어 보인다. 기본 = `--map-halo`. */
halo = haloColor(),
/** 글 폭 기억(582 · 글 → 폭 px) — 부르는 쪽이 글꼴마다 하나 둠 · 없으면 그때마다 잼. */
widths?: Map<string, number>,
): void {
const affine = affineOf(view);
const margin = CULL_MARGIN;
@@ -600,7 +616,12 @@ export function drawPreparedLabels(
const y = feature.labelAnchorY * affine.ay + affine.by;
if (x < -margin || x > view.width + margin) continue;
if (y < -margin || y > view.height + margin) continue;
const half = context.measureText(feature.labelText).width / 2 + LABEL_GAP_PX;
let width = widths?.get(feature.labelText);
if (width === undefined) {
width = context.measureText(feature.labelText).width;
widths?.set(feature.labelText, width);
}
const half = width / 2 + LABEL_GAP_PX;
if (
drawn.some(
(item) => Math.abs(item.x - x) < item.half + half && Math.abs(item.y - y) < LABEL_ROW_PX,
@@ -13,11 +13,44 @@ import type {
GeoJsonCollection,
GeoJsonGeometry,
Normalizer,
PartLod,
PreparedFeature,
PreparedLayer,
PreparedPart,
} from "../B04_PreProcess_UI_MapRender";
/** 줌 단계 무게(정규화 좌표 · 큰 → 작은 · 4 배 간격) — 화면 1 폭 ≈ 1,400 px 이면 다 줌아웃 ~ 2,000 배. */
const LOD_THRESHOLDS = [4 ** -5, 4 ** -6, 4 ** -7, 4 ** -8, 4 ** -9, 4 ** -10];
/** 이보다 짧은 조각은 색인 없이 전부 훑음(훑기가 이미 짧음). */
const LOD_MIN_VERTICES = 64;
/** 조각 하나의 줌 단계 색인. 짧거나 무게가 없으면 undefined. */
export function buildPartLod(weights: Float64Array | null): PartLod | undefined {
if (!weights || weights.length < LOD_MIN_VERTICES) return undefined;
const lists = LOD_THRESHOLDS.map((threshold) => {
const kept: number[] = [];
for (let v = 0; v < weights.length; v += 1) if (weights[v] >= threshold) kept.push(v);
return Uint32Array.from(kept);
});
return { thresholds: LOD_THRESHOLDS, lists };
}
/** 허용 오차에 맞는 가장 성긴 단 — 「무게 ≥ 오차」 정점을 모두 담음. 맞는 단이 없으면 null(전부 훑음). */
export function pickLodList(lod: PartLod, tolerance: number): Uint32Array | null {
for (let k = 0; k < lod.thresholds.length; k += 1)
if (lod.thresholds[k] <= tolerance) return lod.lists[k];
return null;
}
/** 레이어 선 조각마다 줌 단계 색인을 붙임(제자리 · 582 — 지금 D03 만 부름). */
export function indexLayerLod(layer: PreparedLayer): PreparedLayer {
for (const feature of layer.features) {
if (feature.kind !== "line") continue;
for (const part of feature.parts) part.lod = buildPartLod(part.weights);
}
return layer;
}
function isPoint(value: unknown): value is [number, number] {
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
}