feat(ui): 支持预览postgres的Geometry数据为图形

为 cell detail 面板中的 PostGIS geometry/geography 列添加基于 Canvas 的几何图形预览弹窗
This commit is contained in:
DengQingNian 2026-06-05 12:41:19 +08:00 committed by GitHub
parent ea45e1c331
commit 47ddbd5ce7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 379 additions and 0 deletions

View File

@ -34,6 +34,7 @@ import {
SearchX,
Code2,
Copy,
Eye,
Loader2,
X,
Undo2,
@ -122,11 +123,13 @@ import {
cellDetailEditorText,
defaultCellDetailTab,
formatJsonText,
isGeometryColumnType,
linkedCellDetailTarget,
valueEditorActions,
visibleCellDetailTabs,
type CellDetailTab,
} from "@/lib/cellDetailPresentation";
import { renderWktOnCanvas, isHexGeometry } from "@/lib/geometryPreview";
import {
buildDataGridCellDetail,
buildDataGridColumnDetail,
@ -404,6 +407,7 @@ function typeColorClass(t: string): string {
if (["json", "jsonb", "xml", "array"].includes(base)) return "text-pink-500";
if (["uuid", "uniqueidentifier"].includes(base)) return "text-amber-500";
if (["bytea", "blob", "binary", "varbinary", "image"].includes(base)) return "text-red-400";
if (["geometry", "geography"].includes(base)) return "text-emerald-500";
return "text-muted-foreground";
}
const contextCell = ref<{ rowId: number; rowIndex: number; col: number } | null>(null);
@ -426,6 +430,10 @@ const isResizingDetail = ref(false);
const imagePreviewOpen = ref(false);
const imagePreviewSrc = ref("");
const imagePreviewTitle = ref("");
const sideGeometryPreviewOpen = ref(false);
const dialogGeometryPreviewOpen = ref(false);
const sideGeometryCanvas = ref<HTMLCanvasElement | null>(null);
const dialogGeometryCanvas = ref<HTMLCanvasElement | null>(null);
const transposeRowIndex = ref<number | null>(null);
const showTranspose = ref(false);
const preserveTransposeOnNextResult = ref(false);
@ -2588,6 +2596,28 @@ watch(columnDetailDialogOpen, (open) => {
if (!open) columnDetailDialogColumnIndex.value = null;
});
watch(sideGeometryPreviewOpen, async (open) => {
if (open) {
await nextTick();
const canvas = sideGeometryCanvas.value;
const detail = activeCellDetail.value;
if (canvas && detail && detail.value !== null) {
renderWktOnCanvas(canvas, String(detail.value));
}
}
});
watch(dialogGeometryPreviewOpen, async (open) => {
if (open) {
await nextTick();
const canvas = dialogGeometryCanvas.value;
const detail = dialogCellDetail.value;
if (canvas && detail && detail.value !== null) {
renderWktOnCanvas(canvas, String(detail.value));
}
}
});
const activeCellDetailTabs = computed(() => {
const detail = activeCellDetail.value;
return visibleCellDetailTabs({ isEditable: !!detail?.isEditable });
@ -7380,6 +7410,31 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<!-- Skip hex fallback from backend (unsupported geometry types like TIN/Triangle) -->
<Popover
v-if="
isGeometryColumnType(activeCellDetail.type) &&
activeCellDetail.value !== null &&
!isEditingDetail &&
!isHexGeometry(activeCellDetail.value as string)
"
v-model:open="sideGeometryPreviewOpen"
>
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('grid.geometryPreview')">
<Eye class="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-1.5" align="end">
<canvas
v-show="sideGeometryPreviewOpen"
ref="sideGeometryCanvas"
width="400"
height="280"
class="block rounded"
/>
</PopoverContent>
</Popover>
</div>
</div>
<div v-if="activeCellDetail.imagePreviewUrl && !isEditingDetail" class="space-y-1.5">
@ -7771,6 +7826,30 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<!-- Skip hex fallback from backend (unsupported geometry types like TIN/Triangle) -->
<Popover
v-if="
isGeometryColumnType(dialogCellDetail.type) &&
dialogCellDetail.value !== null &&
!isHexGeometry(dialogCellDetail.value as string)
"
v-model:open="dialogGeometryPreviewOpen"
>
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('grid.geometryPreview')">
<Eye class="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-1.5" align="end">
<canvas
v-show="dialogGeometryPreviewOpen"
ref="dialogGeometryCanvas"
width="400"
height="280"
class="block rounded"
/>
</PopoverContent>
</Popover>
</div>
</div>
<a

View File

@ -574,6 +574,7 @@ export default {
valueEditor: "Value Editor",
imagePreview: "Image Preview",
imageLoadFailed: "Image failed to load",
geometryPreview: "Geometry Preview",
zoomIn: "Zoom In",
zoomOut: "Zoom Out",
fitImage: "Fit to Window",

View File

@ -540,6 +540,7 @@ export default {
valueEditor: "Editor de valor",
imagePreview: "Vista previa de imagen",
imageLoadFailed: "No se pudo cargar la imagen",
geometryPreview: "Vista previa de geometría",
zoomIn: "Acercar",
zoomOut: "Alejar",
fitImage: "Ajustar a ventana",

View File

@ -569,6 +569,7 @@ export default {
valueEditor: "值编辑器",
imagePreview: "图片预览",
imageLoadFailed: "图片加载失败",
geometryPreview: "图形预览",
zoomIn: "放大",
zoomOut: "缩小",
fitImage: "适应窗口",

View File

@ -569,6 +569,7 @@ export default {
valueEditor: "儲存格編輯器",
imagePreview: "圖片預覽",
imageLoadFailed: "圖片載入失敗",
geometryPreview: "圖形預覽",
zoomIn: "放大",
zoomOut: "縮小",
fitImage: "適應視窗",

View File

@ -65,6 +65,14 @@ export function isJsonColumnType(columnType: string | undefined): boolean {
return base === "json" || base === "jsonb";
}
export function isGeometryColumnType(columnType: string | undefined): boolean {
const base = (columnType ?? "")
.trim()
.toLowerCase()
.split(/[(:\s]/)[0];
return base === "geometry" || base === "geography";
}
export function canFormatCellDetailJson(value: unknown, columnType?: string): boolean {
if (value === null || value === undefined) return false;
const text = cellDetailRawEditorText(value);

View File

@ -0,0 +1,288 @@
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] === ")" || tokens[i] === ",") {
break;
} 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);
}