feat(ui): 支持预览postgres的Geometry数据为图形
为 cell detail 面板中的 PostGIS geometry/geography 列添加基于 Canvas 的几何图形预览弹窗
This commit is contained in:
parent
ea45e1c331
commit
47ddbd5ce7
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -569,6 +569,7 @@ export default {
|
|||
valueEditor: "值编辑器",
|
||||
imagePreview: "图片预览",
|
||||
imageLoadFailed: "图片加载失败",
|
||||
geometryPreview: "图形预览",
|
||||
zoomIn: "放大",
|
||||
zoomOut: "缩小",
|
||||
fitImage: "适应窗口",
|
||||
|
|
|
|||
|
|
@ -569,6 +569,7 @@ export default {
|
|||
valueEditor: "儲存格編輯器",
|
||||
imagePreview: "圖片預覽",
|
||||
imageLoadFailed: "圖片載入失敗",
|
||||
geometryPreview: "圖形預覽",
|
||||
zoomIn: "放大",
|
||||
zoomOut: "縮小",
|
||||
fitImage: "適應視窗",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue