diff --git a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts index 955af7888..7bcc96ca9 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts @@ -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, ): 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, diff --git a/B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare.ts b/B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare.ts index 5eca0fcaa..5db9d33d4 100644 --- a/B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare.ts +++ b/B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare.ts @@ -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"; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index 250eb3551..55b6d5e09 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -7,6 +7,7 @@ * 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억. * 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음. * 그리기 차례(아래 → 위) = 위성(`scene.image`) → 급경사 → 지적 → 등고선 → 도로 → 세류 → 그은 선. + * 겹 다섯 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억. * ========================================================================== */ import { createIconButton } from "@ui/ui_template_button"; @@ -30,7 +31,10 @@ import { type GeoJsonCollection, type PreparedLayer, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; -import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare"; +import { + indexLayerLod, + prepareLayer, +} from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare"; import { fetchPlanCadastral, fetchPlanRaster, @@ -159,7 +163,7 @@ export function createPlanLayers(opts: { void ensure(key); showSatellite(); sync(); - map.redraw(); + map.redrawBase(); } function showSatellite(): void { @@ -178,7 +182,7 @@ export function createPlanLayers(opts: { if (meta !== at) return; showSatellite(); sync(); - map.redraw(); + map.redrawBase(); }) .catch((error: unknown) => { const text = error instanceof Error ? error.message : String(error); @@ -213,7 +217,7 @@ export function createPlanLayers(opts: { "route", bbox.join(","), ); - data.contour = prepare(collection, ["등고수치"]); + data.contour = indexLayerLod(prepare(collection, ["등고수치"])); } else if (key === "stream") { const collection = await fetchCachedSheetLayer( projectId, @@ -258,8 +262,11 @@ export function createPlanLayers(opts: { drawPreparedLayer(context, layer, map.view, "dot", everyM); }; + /** 등고 글 폭 기억(글꼴 하나 · 582). */ + const labelWidths = new Map(); const layers: PlanMapLayer[] = [ { + base: true, draw: (context, map) => { const slope = on.slope && data.slope; if (!slope) return; @@ -271,6 +278,7 @@ export function createPlanLayers(opts: { }, }, { + base: true, draw: (context, map) => { if (!on.cadastral || !data.cadastral) return; context.globalAlpha = 0.8; @@ -278,6 +286,7 @@ export function createPlanLayers(opts: { }, }, { + base: true, draw: (context, map) => { const contour = on.contour && data.contour; if (!contour) return; @@ -297,10 +306,12 @@ export function createPlanLayers(opts: { 25, map.uprightRad, "rgba(0, 0, 0, 0.6)", + labelWidths, ); }, }, { + base: true, draw: (context, map) => { if (!on.road || !data.road) return; stroke(context, map, data.road, "rgba(0, 0, 0, 0.55)", 3.2); @@ -308,6 +319,7 @@ export function createPlanLayers(opts: { }, }, { + base: true, draw: (context, map) => { if (!on.stream || !data.stream) return; const color = themeColor("--draw-map-stream", "#38bdf8"); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index 642827276..282927445 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -8,6 +8,8 @@ * 회전 `createMapRotation`(B05 그대로 · 단추 `[data-act="rotate-ccw|cw"]` · Ctrl+휠). * 왼쪽 단추 = 겹 몫(점 찍기 · 마크 · 재기) — 왼쪽 끌기 옮기기 없음(설정 창 규칙 = 가운데 단추 옮김 · 휠 방향). * 겹은 돌리기 전 그림 좌표로 그리고 받음 — 누른 자리는 `unrotate` 로 되돌려 넘김. + * 캔버스 두 장(582) — 아래 = 위성 · 바탕 겹(`base`) · 위 = 그은 선 · 재기 · 누름 받음. + * 마우스 움직임 = 위만 다시 그림 · 화면이 바뀌면(옮김 · 확대 · 돌림 · 겹) 둘 다 · 한 화면 한 번(rAF). * ========================================================================== */ import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools"; @@ -66,6 +68,8 @@ export interface ScreenMap { /** 평면 위 겹 — 그리기 · 누름(참 = 받음 · 0 왼쪽 · 2 오른쪽) · 마우스 곁(왼쪽 끌기 중도) · 놓음 · 나감. */ export interface PlanMapLayer { + /** 바탕 겹(아래 캔버스 · 마우스 움직임엔 안 다시 그림). */ + base?: boolean; draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void; press?: (px: number, py: number, map: ScreenMap, button: 0 | 2) => boolean; hover?: (px: number, py: number, map: ScreenMap) => void; @@ -93,8 +97,10 @@ export interface PlanMap { update: (patch: Partial) => void; setLayers: (layers: PlanMapLayer[]) => void; setTools: (tools: Partial) => void; - /** 다시 그림(겹 값이 바뀜). */ + /** 위 겹 다시 그림(그은 선 · 재기 값이 바뀜). */ redraw: () => void; + /** 바탕 겹까지 다시 그림(겹 켬끔 · 받음). */ + redrawBase: () => void; /** 맞출 점이 들어오게 다시 맞춤. */ fit: () => void; /** 검증 창구 — 작업 좌표계 점 → 화면에 보이는 px(돌린 뒤 · 그리기 전이면 null). */ @@ -131,6 +137,8 @@ function fakeMeta(points: Vertex[]): VWorldMeta | null { export function createPlanMap(events: PlanMapEvents): PlanMap { const root = document.createElement("div"); root.className = "d03-plan__map"; + const baseCanvas = document.createElement("canvas"); + baseCanvas.className = "d03-plan__base"; const canvas = document.createElement("canvas"); let marks = { stations: true, slopes: true }; const markButton = (act: "stations" | "slopes", title: string): HTMLButtonElement => { @@ -140,7 +148,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { onClick: () => { marks = { ...marks, [act]: !marks[act] }; button.setAttribute("aria-pressed", String(marks[act])); - draw(); + drawTop(); }, }); return button; @@ -175,7 +183,8 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { }); const toolbar = tools.root; toolbar.classList.add("d03-plan__tools"); - root.append(canvas, toolbar); + root.append(baseCanvas, canvas, toolbar); + const baseContext = baseCanvas.getContext("2d")!; const context = canvas.getContext("2d")!; let scene: PlanMapScene = { meta: null, image: null, fit: [] }; let layers: PlanMapLayer[] = []; @@ -236,33 +245,58 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { fitted = true; } - function draw(): void { - context.clearRect(0, 0, view.width, view.height); + /** 한 장 그림 — 아래 = 위성 + 바탕 겹 · 위 = 나머지 겹. */ + function paint(ctx: CanvasRenderingContext2D, base: boolean): void { + ctx.clearRect(0, 0, view.width, view.height); if (!meta) return; - context.save(); - context.translate(view.width / 2, view.height / 2); - context.rotate(rotation.radians()); - context.translate(-view.width / 2, -view.height / 2); - if (scene.image?.complete && scene.image.naturalWidth && scene.meta) { + ctx.save(); + 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) { const [x0, y0] = normalizedToScreen(view, 0, 0); const [x1, y1] = normalizedToScreen(view, 1, 1); - context.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0); + ctx.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0); } const map = screenMap(); for (const layer of layers) { - context.save(); - layer.draw(context, map); - context.restore(); + if (!layer.base !== !base) continue; + ctx.save(); + layer.draw(ctx, map); + ctx.restore(); } - context.restore(); + ctx.restore(); } + let frame = 0; + let baseDirty = false; + let topDirty = false; + /** 모인 다시 그림을 지금 그림. */ + function flush(): void { + cancelAnimationFrame(frame); + frame = 0; + if (baseDirty) paint(baseContext, true); + if (topDirty) paint(context, false); + baseDirty = topDirty = false; + } + /** 다음 화면에 한 번 — 바탕까지(화면 · 겹이 바뀜) 또는 위만(마우스). */ + function schedule(base: boolean): void { + baseDirty ||= base; + topDirty = true; + if (!frame) frame = requestAnimationFrame(flush); + } + const draw = (): void => schedule(true); + const drawTop = (): void => schedule(false); + function resize(): void { + fitCanvas(baseCanvas, baseContext); const { width, height } = fitCanvas(canvas, context); if (!width || !height) return; view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) }; if (!fitted) refit(); - draw(); + // 크기를 바꾸면 캔버스가 비므로 같은 화면에 바로 그림(빈 화면 한 번 없음) + baseDirty = topDirty = true; + flush(); } /** 누른 화면 자리 → 그림 px(돌리기 전). */ @@ -277,31 +311,31 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { const map = screenMap(); if (layers.some((layer) => layer.press?.(px, py, map, 0))) { canvas.setPointerCapture(event.pointerId); // 끌기가 캔버스 밖으로 나가도 놓음을 받음 - draw(); + drawTop(); } }); canvas.addEventListener("pointerup", (event) => { if (event.button !== 0) return; for (const layer of layers) layer.release?.(); - draw(); + drawTop(); }); canvas.addEventListener("pointermove", (event) => { if (!meta || event.buttons & 4) return; // 가운데 단추 옮기는 중 const [px, py] = at(event); const map = screenMap(); for (const layer of layers) layer.hover?.(px, py, map); - draw(); + drawTop(); }); canvas.addEventListener("pointerleave", () => { for (const layer of layers) layer.leave?.(); - draw(); + drawTop(); }); canvas.addEventListener("contextmenu", (event) => { event.preventDefault(); if (!meta) return; const [px, py] = at(event); const map = screenMap(); - if (layers.some((layer) => layer.press?.(px, py, map, 2))) draw(); + if (layers.some((layer) => layer.press?.(px, py, map, 2))) drawTop(); }); bindRouteEditNavigation({ canvas, @@ -335,7 +369,8 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { if (tools.measure !== undefined) measureButton.setAttribute("aria-pressed", String(tools.measure)); }, - redraw: draw, + redraw: drawTop, + redrawBase: draw, fit() { refit(); draw(); @@ -346,6 +381,9 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { return rotation.rerotate(x, y); }, rotationDeg: () => (rotation.radians() * 180) / Math.PI, - dispose: () => observer.disconnect(), + dispose: () => { + observer.disconnect(); + cancelAnimationFrame(frame); + }, }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index 1b73113d8..fc50a7191 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -25,6 +25,17 @@ touch-action: none; } +/* 582 바탕 캔버스 — 위 캔버스 밑에 겹침 · 누름은 위가 받음 */ +.d03-plan__map .d03-plan__base { + position: absolute; + inset: 0; + pointer-events: none; +} + +.d03-plan__map canvas:not(.d03-plan__base) { + position: relative; +} + /* 571-1 — 왼쪽은 겹 단추줄(222px) 오른쪽까지 · 그 사이가 모자라면 도구줄이 가로 밀림 · 오른쪽 붙임 그대로 */ .d03-plan__tools { position: absolute; diff --git a/resources/tester/helper_582_contour_lod.cjs b/resources/tester/helper_582_contour_lod.cjs new file mode 100644 index 000000000..eaba4ca55 --- /dev/null +++ b/resources/tester/helper_582_contour_lod.cjs @@ -0,0 +1,189 @@ +/* 582 등고선 줌 단계 색인 헬퍼 — `B04_PreProcess_UI_MapRender_Prepare.ts` 의 `indexLayerLod` 를 붙인 + * 레이어와 안 붙인 레이어를 같은 `drawPreparedLayer` 로 가짜 캔버스에 그려 그리기 명령(차례 · 좌표)을 + * 줌 · 자리마다 맞대고 JSON 으로 낸다. 글 폭 기억(`drawPreparedLabels` widths)도 같은 방식. + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const { load } = makeLoader({ root: ROOT, ui: () => ({ themeColor: () => "" }) }); +const render = load("B04_PreProcess", "B04_PreProcess_UI_MapRender.ts"); +const prep = load("B04_PreProcess", "logic", "B04_PreProcess_UI_MapRender_Prepare.ts"); + +const W = 1400; +const H = 900; +const META = { width_meters: 10000, height_meters: 8000 }; +const NORMALIZER = { lonMin: 127, latMin: 36, lonRange: 0.1, latRange: 0.08, aspect: 1.25 }; + +/** 그리기 명령을 글로 적는 가짜 캔버스. */ +function recorder() { + const log = []; + const rec = + (name) => + (...args) => + log.push(`${name}(${args.map((v) => (typeof v === "number" ? v.toFixed(6) : v)).join(",")})`); + let measured = 0; + return { + log, + measured: () => measured, + beginPath: rec("beginPath"), + moveTo: rec("moveTo"), + lineTo: rec("lineTo"), + closePath: rec("closePath"), + stroke: rec("stroke"), + arc: rec("arc"), + fill: rec("fill"), + save: rec("save"), + restore: rec("restore"), + translate: rec("translate"), + rotate: rec("rotate"), + strokeText: rec("strokeText"), + fillText: rec("fillText"), + measureText: (text) => { + measured += 1; + return { width: text.length * 6.1 }; + }, + }; +} + +/** 정해진 씨앗의 난수(결과가 매번 같게). */ +function random(seed) { + let s = seed >>> 0; + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 2 ** 32; + }; +} + +/** 등고선 비슷한 가닥 — 구불구불한 열린 선 · 닫힌 고리 · 짧은 조각. */ +function collection() { + const rnd = random(582); + const features = []; + const lonLat = (x, y) => [127 + x * 0.1, 36 + (1 - y) * 0.08]; + for (let f = 0; f < 60; f += 1) { + const kind = f % 3; // 0 열린 긴 선 · 1 닫힌 고리 · 2 짧은 조각 + const n = kind === 2 ? 3 + Math.floor(rnd() * 40) : 200 + Math.floor(rnd() * 1800); + const cx = rnd(); + const cy = rnd(); + const pts = []; + if (kind === 1) { + const r = 0.02 + rnd() * 0.2; + for (let i = 0; i < n; i += 1) { + const a = (i / n) * Math.PI * 2; + const wobble = 1 + 0.15 * Math.sin(a * 7 + f) + 0.01 * (rnd() - 0.5); + pts.push(lonLat(cx + r * wobble * Math.cos(a), cy + r * wobble * Math.sin(a))); + } + pts.push(pts[0]); + } else { + let x = cx; + let y = cy; + let heading = rnd() * Math.PI * 2; + for (let i = 0; i < n; i += 1) { + heading += (rnd() - 0.5) * 0.6; + const step = 0.0004 + rnd() * 0.0008; + x += Math.cos(heading) * step; + y += Math.sin(heading) * step; + pts.push(lonLat(x, y)); + } + } + const geometry = + kind === 1 + ? { type: "Polygon", coordinates: [pts] } + : f % 5 === 0 + ? { type: "MultiLineString", coordinates: [pts, pts.slice(0, Math.max(2, n >> 1))] } + : { type: "LineString", coordinates: pts }; + features.push({ geometry, properties: { 등고수치: 400 + 5 * f } }); + } + return { features }; +} + +const plain = prep.prepareLayer(collection(), NORMALIZER, ["등고수치"]); +const indexed = prep.indexLayerLod(prep.prepareLayer(collection(), NORMALIZER, ["등고수치"])); + +let lodParts = 0; +let shortParts = 0; +for (const feature of indexed.features) + for (const part of feature.parts) { + if (part.lod) lodParts += 1; + else shortParts += 1; + } + +const SCALES = [1, 1.7, 3, 7, 13, 50, 200, 1000, 2000]; +const OFFSETS = [ + [0, 0], + [300, -200], + [-900, 500], +]; +const mapRect = render.computeMapRect( + { width_meters: META.width_meters, height_meters: META.height_meters }, + W, + H, +); +const cases = []; +for (const scale of SCALES) + for (const [ox, oy] of OFFSETS) { + const view = { width: W, height: H, scale, offsetX: ox, offsetY: oy, mapRect }; + const a = recorder(); + const b = recorder(); + for (const everyM of [undefined, 25]) { + render.drawPreparedLayer(a, plain, view, "dot", everyM); + render.drawPreparedLayer(b, indexed, view, "dot", everyM); + } + const widths = new Map(); + const la = recorder(); + const lb = recorder(); + render.drawPreparedLabels(la, plain, view, "#fff", 5, 0, "#000"); + render.drawPreparedLabels(lb, indexed, view, "#fff", 5, 0, "#000", widths); + const measuredFirst = lb.measured(); + render.drawPreparedLabels(lb, indexed, view, "#fff", 5, 0, "#000", widths); // 두 번째 = 기억만 씀 + const measuredSecond = lb.measured() - measuredFirst; + const half = lb.log.length / 2; + cases.push({ + scale, + offset: [ox, oy], + commands: a.log.length, + same: a.log.join("\n") === b.log.join("\n"), + vertices: a.log.filter((line) => line.startsWith("lineTo")).length, + labelsSame: la.log.join("\n") === lb.log.slice(0, half).join("\n"), + labelsRepeatSame: lb.log.slice(0, half).join("\n") === lb.log.slice(half).join("\n"), + labelMeasuredPlain: la.measured(), + labelMeasuredCached: measuredSecond, + }); + } + +// 색인이 훑는 정점 수 — 다 줌아웃(배율 1)의 허용 오차에서 전부 대비 몇 % +const tolerance = 0.75 / (mapRect.width * 1); +let all = 0; +let scanned = 0; +for (const feature of indexed.features) + for (const part of feature.parts) { + all += part.coords.length / 2; + const list = part.lod ? prep.pickLodList(part.lod, tolerance) : null; + scanned += list ? list.length : part.coords.length / 2; + } + +// 고른 단 = 「무게 ≥ 오차」 정점을 모두 담음(여러 오차) +let supersetOk = true; +for (const tol of [1e-2, 1e-3, 3e-4, 1e-4, 2e-5, 5e-6, 1e-6, 1e-7]) + for (const feature of indexed.features) + for (const part of feature.parts) { + if (!part.lod) continue; + const list = prep.pickLodList(part.lod, tol); + if (!list) continue; + const inList = new Set(list); + for (let v = 0; v < part.weights.length; v += 1) + if (part.weights[v] >= tol && !inList.has(v)) supersetOk = false; + for (let k = 1; k < list.length; k += 1) if (list[k] <= list[k - 1]) supersetOk = false; + } + +process.stdout.write( + JSON.stringify({ + lodParts, + shortParts, + closedParts: indexed.features.filter((f) => f.parts.some((p) => p.closed)).length, + cases, + scannedRatio: scanned / all, + supersetOk, + noLodFinest: prep.pickLodList(indexed.features[0].parts[0].lod, 1e-9) === null, + }), +); diff --git a/resources/tester/test_582_contour_lod.py b/resources/tester/test_582_contour_lod.py new file mode 100644 index 000000000..40167fd20 --- /dev/null +++ b/resources/tester/test_582_contour_lod.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +"""582 등고선 줌 단계 색인 · 글 폭 기억 — 그림 그대로(D03 먼저). + +- 색인 붙인 레이어 = 안 붙인 레이어와 그리기 명령(차례 · 좌표)이 똑같음 — 여러 줌 · 자리 · + 닫힌 고리 · 짧은 조각(색인 없음) · 간격 거름(25 m) +- 고른 단은 「무게 ≥ 허용 오차」 정점을 모두 담고 원래 차례 · 아주 확대하면 색인 없이 전부 훑음 +- 다 줌아웃에서 훑는 정점이 줄어듦 +- 글 폭 기억 = 같은 글 · 같은 자리 · 두 번째부터 재지 않음 +- 색인 · 바탕 캔버스는 D03 만 켬(B04 · B05 는 안 부름) + +Node 헬퍼(`helper_582_contour_lod.cjs`)가 진짜 TS 를 가짜 캔버스로 돌린다. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).parents[1] + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_582_contour_lod.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_조각_갈래가_다_있음(r): + assert r["lodParts"] > 20 + assert r["shortParts"] > 10 + assert r["closedParts"] > 10 + + +def test_색인으로_그린_명령이_지금과_같음(r): + assert len(r["cases"]) == 27 + for case in r["cases"]: + assert case["same"], case + # 줌 · 자리마다 그린 정점 수가 실제로 달라짐(거름이 일함) + assert len({c["vertices"] for c in r["cases"]}) > 15 + assert all(c["vertices"] > 0 for c in r["cases"] if c["scale"] <= 13) + + +def test_고른_단은_남는_정점을_모두_담음(r): + assert r["supersetOk"] + assert r["noLodFinest"] + + +def test_다_줌아웃_훑기가_줄어듦(r): + assert r["scannedRatio"] < 0.5 + + +def test_글_폭_기억은_같은_글(r): + for case in r["cases"]: + assert case["labelsSame"], case + assert case["labelsRepeatSame"], case + assert case["labelMeasuredCached"] == 0 + assert any(c["labelMeasuredPlain"] > 0 for c in r["cases"]) + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_D03_만_켬(): + layers = _read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts") + assert "indexLayerLod(prepare(collection" in layers + assert layers.count("base: true") == 5 + plan_map = _read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts") + assert "requestAnimationFrame(flush)" in plan_map + assert "drawTop();" in plan_map + users = [ + p.relative_to(ROOT).as_posix() + for folder in ("B04_PreProcess", "B05_Profile", "B06_Section", "A00_Common") + for p in (ROOT / folder).rglob("*.ts") + if "indexLayerLod(" in p.read_text(encoding="utf-8") + ] + assert users == ["B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare.ts"]