Files
dbx/apps/desktop/src/lib/geometryPreview.ts
T

397 lines
12 KiB
TypeScript

type Coord = [number, number];
interface GeometryGroup {
coords: Coord[];
children: GeometryGroup[];
}
function tokenize(wkt: string): (string | number)[] {
const tokens: (string | number)[] = [];
let i = 0;
while (i < wkt.length) {
const ch = wkt[i];
if (ch === "(" || ch === ")" || ch === ",") {
tokens.push(ch);
i++;
} else if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
i++;
} else if ((ch >= "0" && ch <= "9") || ch === "-" || ch === "+" || ch === ".") {
const m = wkt.slice(i).match(/^[+-]?\d*\.?\d+(?:[eE][+-]?\d+)?/);
if (m) {
tokens.push(parseFloat(m[0]));
i += m[0].length;
} else {
i++;
}
} else {
// skip alphabetic keywords (type names, EMPTY, Z, M, etc.)
const word = wkt.slice(i).match(/^[A-Za-z]+/);
if (word) {
i += word[0].length;
} else {
i++;
}
}
}
return tokens;
}
function parseCoordSequence(tokens: (string | number)[], start: number): { coords: Coord[]; end: number } {
const coords: Coord[] = [];
let i = start;
while (i < tokens.length) {
if (typeof tokens[i] === "number") {
const x = tokens[i] as number;
i++;
const y = i < tokens.length && typeof tokens[i] === "number" ? (tokens[i] as number) : 0;
i++;
coords.push([x, y]);
// skip Z/M values
while (i < tokens.length && typeof tokens[i] === "number") i++;
} else if (tokens[i] === ",") {
i++;
} else {
break;
}
}
return { coords, end: i };
}
function parseGroups(tokens: (string | number)[], start: number): { groups: GeometryGroup[]; end: number } {
const groups: GeometryGroup[] = [];
let i = start;
while (i < tokens.length) {
if (tokens[i] === "(") {
const inner = parseGroups(tokens, i + 1);
if (inner.groups.length === 0) {
// leaf group: parse as coordinate sequence
const seq = parseCoordSequence(tokens, i + 1);
groups.push({ coords: seq.coords, children: [] });
i = seq.end;
} else {
groups.push({ coords: [], children: inner.groups });
i = inner.end;
}
if (i < tokens.length && tokens[i] === ")") i++;
} else if (tokens[i] === ")") {
break;
} else if (tokens[i] === ",") {
i++;
} else if (typeof tokens[i] === "number") {
const seq = parseCoordSequence(tokens, i);
groups.push({ coords: seq.coords, children: [] });
i = seq.end;
} else {
i++;
}
}
return { groups, end: i };
}
function flattenCoords(groups: GeometryGroup[]): Coord[] {
const result: Coord[] = [];
for (const g of groups) {
for (const c of g.coords) result.push(c);
const childCoords = flattenCoords(g.children);
for (const c of childCoords) result.push(c);
}
return result;
}
// Walk a group recursively and return the first non-empty coordinate sequence found
function extractCoords(g: GeometryGroup): Coord[] {
if (g.coords.length > 0) return g.coords;
for (const child of g.children) {
const found = extractCoords(child);
if (found.length > 0) return found;
}
return [];
}
// Draw polygon exterior + holes from a group structure
function drawPolygonRings(ctx: CanvasRenderingContext2D, group: GeometryGroup, mapX: (v: number) => number, mapY: (v: number) => number) {
// group.children contains ring groups
const rings = group.children.length > 0 ? group.children : [group];
let ringIndex = 0;
for (const ringGroup of rings) {
const coords = extractCoords(ringGroup);
if (coords.length < 2) continue;
ctx.beginPath();
ctx.moveTo(mapX(coords[0][0]), mapY(coords[0][1]));
for (let j = 1; j < coords.length; j++) {
ctx.lineTo(mapX(coords[j][0]), mapY(coords[j][1]));
}
ctx.closePath();
if (ringIndex === 0) ctx.fill();
ctx.stroke();
ringIndex++;
}
}
function drawGroups(ctx: CanvasRenderingContext2D, groups: GeometryGroup[], type: string, mapX: (v: number) => number, mapY: (v: number) => number) {
if (type === "POINT" || type === "MULTIPOINT") {
const allCoords = flattenCoords(groups);
for (const [x, y] of allCoords) {
ctx.beginPath();
ctx.arc(mapX(x), mapY(y), 3, 0, Math.PI * 2);
ctx.fill();
}
return;
}
if (type === "LINESTRING" || type === "MULTILINESTRING") {
for (const g of groups) {
const coords = extractCoords(g);
if (coords.length >= 2) {
ctx.beginPath();
ctx.moveTo(mapX(coords[0][0]), mapY(coords[0][1]));
for (let i = 1; i < coords.length; i++) {
ctx.lineTo(mapX(coords[i][0]), mapY(coords[i][1]));
}
ctx.stroke();
}
}
return;
}
if (type === "POLYGON") {
for (const g of groups) {
drawPolygonRings(ctx, g, mapX, mapY);
}
return;
}
if (type === "MULTIPOLYGON") {
for (const g of groups) {
if (g.children.length > 0) {
for (const polyChild of g.children) {
drawPolygonRings(ctx, polyChild, mapX, mapY);
}
}
}
return;
}
// GEOMETRYCOLLECTION or unknown: draw all coordinate sequences
for (const g of groups) {
const coords = extractCoords(g);
if (coords.length >= 2) {
ctx.beginPath();
ctx.moveTo(mapX(coords[0][0]), mapY(coords[0][1]));
for (let i = 1; i < coords.length; i++) {
ctx.lineTo(mapX(coords[i][0]), mapY(coords[i][1]));
}
ctx.stroke();
} else if (coords.length === 1) {
const [x, y] = coords[0];
ctx.beginPath();
ctx.arc(mapX(x), mapY(y), 3, 0, Math.PI * 2);
ctx.fill();
}
// recurse for type-less children
if (coords.length === 0) {
drawGroups(ctx, g.children, "", mapX, mapY);
}
}
}
function detectWktType(wkt: string): string {
const m = wkt.trim().match(/^([A-Za-z]+)/);
return m ? m[1].toUpperCase() : "GEOMETRYCOLLECTION";
}
// EWKB parsing on the backend can fall back to hex for unsupported geometry types
// (e.g. TIN, Triangle, CircularString). Don't attempt to render hex as WKT.
export function isHexGeometry(value: string): boolean {
return value.startsWith("0x");
}
export function renderWktOnCanvas(canvas: HTMLCanvasElement, wkt: string): void {
if (isHexGeometry(wkt)) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#888";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.fillText("Preview not available for binary format", canvas.width / 2, canvas.height / 2);
return;
}
const type = detectWktType(wkt);
const tokens = tokenize(wkt);
const { groups } = parseGroups(tokens, 0);
const allCoords = flattenCoords(groups);
const ctx = canvas.getContext("2d");
if (!ctx) return;
const W = canvas.width;
const H = canvas.height;
const pad = 20;
ctx.clearRect(0, 0, W, H);
if (allCoords.length === 0) {
ctx.fillStyle = "#888";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.fillText("Empty geometry", W / 2, H / 2);
return;
}
let minX = Infinity,
maxX = -Infinity,
minY = Infinity,
maxY = -Infinity;
for (const [x, y] of allCoords) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
if (minX === maxX && minY === maxY) {
minX -= 1;
maxX += 1;
minY -= 1;
maxY += 1;
}
const rangeX = maxX - minX || 1;
const rangeY = maxY - minY || 1;
const scale = Math.min((W - pad * 2) / rangeX, (H - pad * 2) / rangeY);
const midX = (minX + maxX) / 2;
const midY = (minY + maxY) / 2;
const mapX = (x: number) => (x - midX) * scale + W / 2;
const mapY = (y: number) => -(y - midY) * scale + H / 2;
ctx.strokeStyle = "#3b82f6";
ctx.fillStyle = "rgba(59, 130, 246, 0.15)";
ctx.lineWidth = 1.5;
ctx.lineJoin = "round";
ctx.lineCap = "round";
drawGroups(ctx, groups, type, mapX, mapY);
}
/** GeoJSON geometry type. */
export interface GeoJsonGeometry {
type: string;
coordinates?: any;
geometries?: (GeoJsonGeometry | null)[];
}
/**
* Convert a WKT geometry string to a GeoJSON geometry object.
* Returns `null` for binary (hex) geometries or parse failures.
*/
export function wktToGeoJson(wkt: string): GeoJsonGeometry | null {
if (isHexGeometry(wkt)) return null;
const type = detectWktType(wkt);
if (type === "GEOMETRYCOLLECTION") return geometryCollectionToGeoJson(wkt);
const tokens = tokenize(wkt);
const { groups } = parseGroups(tokens, 0);
if (groups.length === 0) return null;
try {
switch (type) {
case "POINT":
return pointToGeoJson(groups);
case "MULTIPOINT":
return multiPointToGeoJson(groups);
case "LINESTRING":
return lineStringToGeoJson(groups);
case "MULTILINESTRING":
return multiLineStringToGeoJson(groups);
case "POLYGON":
return polygonToGeoJson(groups);
case "MULTIPOLYGON":
return multiPolygonToGeoJson(groups);
default:
return null;
}
} catch {
return null;
}
}
// ── GeoJSON conversion helpers ─────────────────────────────────────────────
function getLeafCoords(g: GeometryGroup): number[][] {
if (g.coords.length > 0) return g.coords;
for (const child of g.children) {
const found = getLeafCoords(child);
if (found.length > 0) return found;
}
return [];
}
function pointToGeoJson(groups: GeometryGroup[]): GeoJsonGeometry {
const leaf = getLeafCoords(groups[0]);
return { type: "Point", coordinates: leaf[0] ?? [0, 0] };
}
function multiPointToGeoJson(groups: GeometryGroup[]): GeoJsonGeometry {
if (groups[0].coords.length > 0) {
return { type: "MultiPoint", coordinates: groups[0].coords };
}
const coords = groups[0].children.map((c) => getLeafCoords(c)[0] ?? [0, 0]);
return { type: "MultiPoint", coordinates: coords };
}
function lineStringToGeoJson(groups: GeometryGroup[]): GeoJsonGeometry {
return { type: "LineString", coordinates: getLeafCoords(groups[0]) };
}
function multiLineStringToGeoJson(groups: GeometryGroup[]): GeoJsonGeometry {
const lines = groups[0].children;
const coords = lines.map((l) => getLeafCoords(l));
return { type: "MultiLineString", coordinates: coords };
}
function polygonToGeoJson(groups: GeometryGroup[]): GeoJsonGeometry {
const rings = groups[0].children.map((c) => getLeafCoords(c));
return { type: "Polygon", coordinates: rings };
}
function multiPolygonToGeoJson(groups: GeometryGroup[]): GeoJsonGeometry {
const polygons = groups[0].children;
const coords = polygons.map((p) => p.children.map((c) => getLeafCoords(c)));
return { type: "MultiPolygon", coordinates: coords };
}
function geometryCollectionToGeoJson(wkt: string): GeoJsonGeometry | null {
const inner = wkt.trim().replace(/^GEOMETRYCOLLECTION\s*/i, "");
if (!inner.startsWith("(") || !inner.endsWith(")")) return null;
const content = inner.slice(1, -1).trim();
if (!content) return null;
const parts = splitGeomCollectionMembers(content);
return {
type: "GeometryCollection",
geometries: parts.map((p) => wktToGeoJson(p)),
};
}
/** Split GEOMETRYCOLLECTION content into individual WKT geometry strings. */
function splitGeomCollectionMembers(content: string): string[] {
const parts: string[] = [];
let depth = 0;
let start = 0;
let i = 0;
while (i < content.length) {
if (content[i] === "(") depth++;
else if (content[i] === ")") depth--;
else if (content[i] === "," && depth === 0) {
parts.push(content.slice(start, i).trim());
start = i + 1;
}
i++;
}
const last = content.slice(start).trim();
if (last) parts.push(last);
return parts;
}