feat(B03): 부속 파일 카드에 파일 속 값 표시·래스터 썸네일 점선 테두리

개수만 보이던 부속 파일 카드에 파일 안의 중요한 값을 함께 보임.

- .shx — 도형 종류(폴리라인 등)와 범위 크기를 머리글에서 읽음.
- .dbf — 속성 이름 목록을 머리글 서술자에서 읽음(앞 4개).
- .cpg — 같은 세트 .dbf 의 첫 글자 속성을 그 인코딩으로 읽어 견본으로 보임.
- .prj — 중앙자오선(투영 매개변수)과 길이 단위. proj 문자열 변환을 쓰지 않아
  정보 손실 경고가 나지 않음.
- 카드 표시 줄 수를 2 → 3 으로 늘리고, 길이 단위 표기를 m 으로 통일.
- GeoTIFF 썸네일에 다른 미리보기와 같은 점선 테두리.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 22:16:14 +09:00
co-authored by Claude Opus 5
parent 973ac7138f
commit 41c1771fad
3 changed files with 180 additions and 14 deletions
+68 -7
View File
@@ -99,9 +99,33 @@ export function readThumbnail(metadata: Record<string, unknown> | undefined): st
return typeof raw === "string" && raw.startsWith("data:image/") ? raw : null;
}
/** SHP/SHX 머리글의 도형 종류 코드 — 카드에 「무엇이 들었나」로 보인다. */
const SHAPE_TYPE_LABELS: Record<number, string> = {
0: "빈 도형",
1: "점",
3: "폴리라인",
5: "폴리곤",
8: "다중점",
11: "점(3D)",
13: "폴리라인(3D)",
15: "폴리곤(3D)",
18: "다중점(3D)",
21: "점(M)",
23: "폴리라인(M)",
25: "폴리곤(M)",
28: "다중점(M)",
31: "복합면",
};
/** 129.002890277778 → 「129.0029°E」. */
function meridianLabel(degrees: number): string {
const rounded = Math.abs(Math.round(degrees * 10000) / 10000);
return `${rounded}°${degrees < 0 ? "W" : "E"}`;
}
/**
* 그릴 도형이 없는 부속 파일(shx·dbf·cpg·prj·tfw)에서 **한눈에 구분되는 값**을 뽑는다
* (2026-09-04 사용자 지시). 그림 대신 이 값을 큰 글씨로 보인다.
* 그릴 도형이 없는 부속 파일(shx·dbf·cpg·prj·tfw)에서 **파일 안의 중요한 값**을 뽑는다
* (2026-09-04 사용자 지시 — 개수만이 아니라 내용이 보이게). 첫 줄은 굵게, 나머지는 작게.
*/
export function readFacts(metadata: Record<string, unknown> | undefined): string[] {
if (!metadata) return [];
@@ -109,15 +133,38 @@ export function readFacts(metadata: Record<string, unknown> | undefined): string
const number = (value: unknown): number | null => finite(value);
const text = (value: unknown): string | null =>
typeof value === "string" && value.trim() ? value.trim() : null;
const round = (value: number): string => Math.round(value).toLocaleString();
// SHX — 도형 종류·개수와 그 도형이 덮는 범위.
const shapes = number(metadata.shape_count);
if (shapes !== null) facts.push(`도형 ${shapes.toLocaleString()}`);
if (shapes !== null) {
const kind = SHAPE_TYPE_LABELS[number(metadata.shape_type) ?? -1];
const count = `도형 ${shapes.toLocaleString()}`;
facts.push(kind ? `${kind} · ${count}` : count);
const width = number(metadata.extent_width_m);
const height = number(metadata.extent_height_m);
if (width !== null && height !== null) facts.push(`범위 ${round(width)}×${round(height)} m`);
}
// DBF — 레코드·속성 개수와 속성 이름(무엇이 든 표인지).
const records = number(metadata.record_count);
if (records !== null) facts.push(`레코드 ${records.toLocaleString()}`);
const fields = number(metadata.field_count);
if (fields !== null) facts.push(`속성 ${fields}`);
if (records !== null) {
const count = `레코드 ${records.toLocaleString()}`;
facts.push(fields !== null ? `${count} · 속성 ${fields}` : count);
}
const names = Array.isArray(metadata.field_names)
? metadata.field_names.filter((name): name is string => typeof name === "string")
: [];
if (names.length) facts.push(names.slice(0, 4).join(", ") + (names.length > 4 ? " …" : ""));
// CPG — 인코딩 이름과 그 인코딩으로 읽은 글자 견본(깨지면 눈에 보인다).
const encoding = text(metadata.encoding);
if (encoding) facts.push(`인코딩 ${encoding}`);
const sample = text(metadata.encoding_sample);
if (sample) facts.push(`견본 「${sample}`);
// TFW — 픽셀 크기와 회전.
const pixelX = number(metadata.pixel_size_x);
const pixelY = number(metadata.pixel_size_y);
if (pixelX !== null && pixelY !== null) {
@@ -126,12 +173,26 @@ export function readFacts(metadata: Record<string, unknown> | undefined): string
(number(metadata.rotation_x) ?? 0) !== 0 || (number(metadata.rotation_y) ?? 0) !== 0;
facts.push(rotated ? "회전 있음" : "회전 없음");
}
// PRJ 는 좌표계 이름이 곧 구분값이다.
// PRJ — 좌표계 이름·EPSG 와 원점(중앙자오선)·길이 단위.
const name = text(metadata.name);
const epsgLabel = readCrsLabel(metadata);
if (name) facts.push(epsgLabel ? `${name} (EPSG:${epsgLabel})` : name);
else if (epsgLabel && facts.length === 0) facts.push(`EPSG:${epsgLabel}`);
return facts.slice(0, 2);
const meridian = number(metadata.central_meridian_deg);
const unit = text(metadata.unit_name);
const origin = [
meridian === null ? null : `중앙자오선 ${meridianLabel(meridian)}`,
unit ? `단위 ${/^met(er|re)s?$/i.test(unit) ? "m" : unit}` : null,
].filter((part): part is string => part !== null);
if (origin.length) facts.push(origin.join(" · "));
const vertical = metadata.vertical_crs as Record<string, unknown> | null | undefined;
if (vertical && typeof vertical === "object") {
const verticalName = text(vertical.name);
if (verticalName) facts.push(`높이 기준 ${verticalName}`);
}
return facts.slice(0, 3);
}
/**