diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue
index a998a4e98b..a488cfb297 100644
--- a/apps/desktop/src/components/grid/DataGrid.vue
+++ b/apps/desktop/src/components/grid/DataGrid.vue
@@ -1,5 +1,5 @@
@@ -111,6 +111,8 @@ import {
} from "@/lib/dataGridTranspose";
import { matchesRowStatusFilter, type RowStatus, type RowStatusFilter } from "@/lib/gridRowStatus";
import { displayCellValue, type CellValue } from "@/lib/cellValue";
+import { getApplicablePreviewActions } from "@/lib/resultPreviewRegistry";
+import "@/lib/previewHandlers/geometryMapPreview";
import {
BINARY_CELL_DOWNLOAD_MODES,
binaryCellDisplayText,
@@ -454,6 +456,8 @@ const sideGeometryPreviewOpen = ref(false);
const dialogGeometryPreviewOpen = ref(false);
const sideGeometryCanvas = ref(null);
const dialogGeometryCanvas = ref(null);
+const previewDialogOpen = ref(false);
+const previewDialogConfig = shallowRef<{ component: any; props: Record } | null>(null);
const transposeRowIndex = ref(null);
const showTranspose = ref(false);
const preserveTransposeOnNextResult = ref(false);
@@ -1529,6 +1533,12 @@ const visibleColumnTypes = computed(() =>
}),
);
const visibleColumnCount = computed(() => visibleColumnIndexes.value.length);
+
+/** Preview actions from the result preview registry for the current result. */
+const previewActions = computed(() => {
+ if (!props.result) return [];
+ return getApplicablePreviewActions(props.result);
+});
const displayableColumnCount = computed(() => displayableColumnIndexes.value.length);
const hiddenColumnCount = computed(() => displayableColumnCount.value - visibleColumnCount.value);
const allNullColumnIndexesForResult = computed(() =>
@@ -2671,6 +2681,18 @@ function exportSelectedRowsSql() {
return exportSql(affectedRowIds());
}
+function executePreviewAction(action: { execute: (ctx: any) => any }) {
+ const config = action.execute({
+ result: props.result,
+ selectedRowIds: affectedRowIds(),
+ displayRowRefs: displayRowRefs.value,
+ });
+ if (config) {
+ previewDialogConfig.value = config;
+ previewDialogOpen.value = true;
+ }
+}
+
function isRowActive(index: number): boolean {
const item = displayItemAt(index);
if (item && isRowSelected(item.id)) return true;
@@ -5820,6 +5842,24 @@ const gridContextMenuItems = computed(() => {
// 8. Export submenu
items.push(exportSubmenu());
+ // 9. Preview actions from registry (e.g. geometry map preview)
+ if (!contextHeaderColumn.value && contextCell.value) {
+ const colType = props.result.column_types?.[contextCell.value.col];
+ if (colType && isGeometryColumnType(colType)) {
+ const actions = previewActions.value;
+ if (actions.length > 0) {
+ items.push({ label: "", separator: true });
+ for (const action of actions) {
+ items.push({
+ label: t("grid.layerPreview"),
+ action: () => executePreviewAction(action),
+ icon: action.icon,
+ });
+ }
+ }
+ }
+ }
+
return items;
});
@@ -8636,6 +8676,12 @@ const gridContextMenuItems = computed(() => {
@confirm="confirmDeleteRow"
/>
+
diff --git a/apps/desktop/src/components/grid/LayerPreviewDialog.vue b/apps/desktop/src/components/grid/LayerPreviewDialog.vue
new file mode 100644
index 0000000000..707f4e4656
--- /dev/null
+++ b/apps/desktop/src/components/grid/LayerPreviewDialog.vue
@@ -0,0 +1,584 @@
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/env.d.ts b/apps/desktop/src/env.d.ts
index fc812394b5..c8d055a418 100644
--- a/apps/desktop/src/env.d.ts
+++ b/apps/desktop/src/env.d.ts
@@ -5,3 +5,11 @@ declare module "*.vue" {
const component: DefineComponent<{}, {}, any>;
export default component;
}
+
+declare module "dom-to-image-more" {
+ const domtoimage: {
+ toPng(node: HTMLElement, options?: Record): Promise;
+ toBlob(node: HTMLElement, options?: Record): Promise;
+ };
+ export default domtoimage;
+}
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index adbf69bc4e..b9c3a22a60 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -613,6 +613,7 @@ export default {
imagePreview: "Image Preview",
imageLoadFailed: "Image failed to load",
geometryPreview: "Geometry Preview",
+ layerPreview: "Layer Preview",
zoomIn: "Zoom In",
zoomOut: "Zoom Out",
fitImage: "Fit to Window",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index a3a580d6ab..d89fc92d6c 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -552,6 +552,7 @@ export default {
imagePreview: "Vista previa de imagen",
imageLoadFailed: "No se pudo cargar la imagen",
geometryPreview: "Vista previa de geometría",
+ layerPreview: "Vista previa de capa",
zoomIn: "Acercar",
zoomOut: "Alejar",
fitImage: "Ajustar a ventana",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 5ae5c67576..0bb0c5d4c2 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -591,6 +591,7 @@ export default {
imagePreview: "Anteprima Immagine",
imageLoadFailed: "Caricamento immagine non riuscito",
geometryPreview: "Anteprima Geometria",
+ layerPreview: "Anteprima Layer",
zoomIn: "Ingrandisci",
zoomOut: "Rimpicciolisci",
fitImage: "Adatta alla Finestra",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index ae3173710b..dd2e398e37 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -590,6 +590,7 @@ export default {
imagePreview: "Visualização de Imagem",
imageLoadFailed: "Falha ao carregar a imagem",
geometryPreview: "Visualização de Geometria",
+ layerPreview: "Visualização de Camada",
zoomIn: "Aproximar",
zoomOut: "Afastar",
fitImage: "Ajustar à Janela",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 8e1d34accb..4305cddf55 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -608,6 +608,7 @@ export default {
imagePreview: "图片预览",
imageLoadFailed: "图片加载失败",
geometryPreview: "图形预览",
+ layerPreview: "图层预览",
zoomIn: "放大",
zoomOut: "缩小",
fitImage: "适应窗口",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index e41c709d37..87e95a4515 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -581,6 +581,7 @@ export default {
imagePreview: "圖片預覽",
imageLoadFailed: "圖片載入失敗",
geometryPreview: "圖形預覽",
+ layerPreview: "圖層預覽",
zoomIn: "放大",
zoomOut: "縮小",
fitImage: "適應視窗",
diff --git a/apps/desktop/src/lib/geometryPreview.ts b/apps/desktop/src/lib/geometryPreview.ts
index 4b53911ef6..ae5a6b4897 100644
--- a/apps/desktop/src/lib/geometryPreview.ts
+++ b/apps/desktop/src/lib/geometryPreview.ts
@@ -286,3 +286,120 @@ export function renderWktOnCanvas(canvas: HTMLCanvasElement, wkt: string): void
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;
+}
diff --git a/apps/desktop/src/lib/previewHandlers/geometryMapPreview.ts b/apps/desktop/src/lib/previewHandlers/geometryMapPreview.ts
new file mode 100644
index 0000000000..4b57111d1b
--- /dev/null
+++ b/apps/desktop/src/lib/previewHandlers/geometryMapPreview.ts
@@ -0,0 +1,84 @@
+import { Map as MapIcon } from "@lucide/vue";
+import { registerPreviewAction } from "@/lib/resultPreviewRegistry";
+import { wktToGeoJson } from "@/lib/geometryPreview";
+import LayerPreviewDialog from "@/components/grid/LayerPreviewDialog.vue";
+
+registerPreviewAction({
+ id: "geometry-map-preview",
+ label: "grid.layerPreview",
+ icon: MapIcon,
+ isAvailable(result) {
+ return (result.column_types ?? []).some((t) => {
+ const base = (t ?? "")
+ .trim()
+ .toLowerCase()
+ .split(/[(:\s]/)[0];
+ return base === "geometry" || base === "geography";
+ });
+ },
+ execute(ctx) {
+ const geomIndices = geometryColumnIndices(ctx.result.column_types);
+ if (geomIndices.length === 0) return null;
+
+ const features: Record[] = [];
+ const seen = new Set();
+
+ for (const ref of ctx.displayRowRefs) {
+ if (ref.isNew) continue;
+ // Only include selected rows if there's an active selection
+ if (ctx.selectedRowIds.length > 0 && !ctx.selectedRowIds.includes(ref.id)) continue;
+ const row = ctx.result.rows[ref.sourceIndex];
+ if (!row) continue;
+ for (const colIdx of geomIndices) {
+ const raw = row[colIdx];
+ if (raw === null || raw === undefined) continue;
+ const wkt = String(raw);
+ if (wkt.startsWith("0x") || seen.has(wkt)) continue;
+ seen.add(wkt);
+ const geom = wktToGeoJson(wkt);
+ if (geom) {
+ // Include all non-geometry column values as properties for labelling.
+ const props: Record = {
+ _column: ctx.result.columns[colIdx],
+ _row: ref.sourceIndex,
+ };
+ for (let c = 0; c < ctx.result.columns.length; c++) {
+ if (geomIndices.includes(c)) continue; // skip geometry columns
+ const colName = ctx.result.columns[c] ?? `col_${c}`;
+ const val = row[c];
+ props[colName] = val ?? null;
+ }
+ features.push({
+ type: "Feature",
+ geometry: geom,
+ properties: props,
+ });
+ }
+ }
+ }
+
+ if (features.length === 0) return null;
+
+ const fc = { type: "FeatureCollection", features };
+
+ return {
+ component: LayerPreviewDialog,
+ props: {
+ geojson: JSON.stringify(fc),
+ },
+ };
+ },
+});
+
+function geometryColumnIndices(columnTypes: readonly string[] | undefined): number[] {
+ if (!columnTypes) return [];
+ const indices: number[] = [];
+ for (let i = 0; i < columnTypes.length; i++) {
+ const base = (columnTypes[i] ?? "")
+ .trim()
+ .toLowerCase()
+ .split(/[(:\s]/)[0];
+ if (base === "geometry" || base === "geography") indices.push(i);
+ }
+ return indices;
+}
diff --git a/apps/desktop/src/lib/resultPreviewRegistry.ts b/apps/desktop/src/lib/resultPreviewRegistry.ts
new file mode 100644
index 0000000000..065f378c1b
--- /dev/null
+++ b/apps/desktop/src/lib/resultPreviewRegistry.ts
@@ -0,0 +1,63 @@
+import type { Component } from "vue";
+import type { QueryResult } from "@/types/database";
+
+/**
+ * Configuration returned by a preview action when the user activates it.
+ * DataGrid renders the component with the given props in a dialog.
+ */
+export interface PreviewDialogConfig {
+ component: Component;
+ props: Record;
+}
+
+/**
+ * Context passed to a preview action's `execute` method.
+ */
+export interface PreviewActionContext {
+ result: QueryResult;
+ selectedRowIds: readonly number[];
+ displayRowRefs: readonly { id: number; sourceIndex: number; isNew: boolean }[];
+}
+
+/**
+ * A preview action that can appear in the result grid's context menu.
+ * Handlers register themselves and are discovered by DataGrid via the registry.
+ */
+export interface PreviewAction {
+ /** Unique identifier for this action. */
+ id: string;
+ /** Display label in the context menu. */
+ label: string;
+ /** Optional icon component for the menu item. */
+ icon?: Component;
+ /** Check whether this action should be shown for the given query result. */
+ isAvailable(result: Pick): boolean;
+ /**
+ * Execute the action. Returns a dialog configuration to render, or null
+ * if nothing to show (e.g. no applicable data in selected rows).
+ */
+ execute(ctx: PreviewActionContext): PreviewDialogConfig | null;
+}
+
+const actions = new Map();
+
+/**
+ * Register a preview action that will be offered in the result grid
+ * context menu when its `isAvailable` check passes.
+ */
+export function registerPreviewAction(action: PreviewAction): void {
+ actions.set(action.id, action);
+}
+
+/**
+ * Get all registered actions that are applicable for the given result.
+ */
+export function getApplicablePreviewActions(result: Pick): PreviewAction[] {
+ const applicable: PreviewAction[] = [];
+ for (const action of actions.values()) {
+ if (action.isAvailable(result)) {
+ applicable.push(action);
+ }
+ }
+ return applicable;
+}
diff --git a/package.json b/package.json
index ff01f6df4d..34a7333053 100644
--- a/package.json
+++ b/package.json
@@ -55,7 +55,9 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror": "^6.0.2",
+ "dom-to-image-more": "^3.7.2",
"echarts": "^6.1.0",
+ "leaflet": "^1.9.4",
"marked": "^18.0.4",
"pinia": "^3.0.0",
"reka-ui": "^2.9.8",
@@ -75,6 +77,7 @@
"@oxlint/binding-darwin-arm64": "1.68.0",
"@tailwindcss/vite": "^4.3.0",
"@tauri-apps/cli": "^2.11.2",
+ "@types/leaflet": "^1.9.21",
"@types/node": "^25.9.1",
"@types/splitpanes": "^2.2.6",
"@vitejs/plugin-vue": "^6.0.7",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 509ce9bd52..f4a24b47d9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -98,9 +98,15 @@ importers:
codemirror:
specifier: ^6.0.2
version: 6.0.2
+ dom-to-image-more:
+ specifier: ^3.7.2
+ version: 3.7.2
echarts:
specifier: ^6.1.0
version: 6.1.0
+ leaflet:
+ specifier: ^1.9.4
+ version: 1.9.4
marked:
specifier: ^18.0.4
version: 18.0.4
@@ -153,6 +159,9 @@ importers:
'@tauri-apps/cli':
specifier: ^2.11.2
version: 2.11.2
+ '@types/leaflet':
+ specifier: ^1.9.21
+ version: 1.9.21
'@types/node':
specifier: ^25.9.1
version: 25.9.1
@@ -1559,12 +1568,18 @@ packages:
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+ '@types/geojson@7946.0.16':
+ resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
+
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+ '@types/leaflet@1.9.21':
+ resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
+
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
@@ -2135,6 +2150,9 @@ packages:
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+ dom-to-image-more@3.7.2:
+ resolution: {integrity: sha512-uQf+pHv6eQhgfI8t2bFuinV0KsPyT8TZgCLwcSU8uBVgN9v6leb0mMpvp6HQAlAcplP3NCcGjxbdqef6pTzvmw==}
+
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
@@ -2685,6 +2703,9 @@ packages:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
+ leaflet@1.9.4:
+ resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
+
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -4979,12 +5000,18 @@ snapshots:
'@types/estree@1.0.9': {}
+ '@types/geojson@7946.0.16': {}
+
'@types/hast@3.0.4':
dependencies:
'@types/unist': 3.0.3
'@types/json-schema@7.0.15': {}
+ '@types/leaflet@1.9.21':
+ dependencies:
+ '@types/geojson': 7946.0.16
+
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
@@ -5620,6 +5647,8 @@ snapshots:
domhandler: 5.0.3
entities: 4.5.0
+ dom-to-image-more@3.7.2: {}
+
domelementtype@2.3.0: {}
domhandler@5.0.3:
@@ -6209,6 +6238,8 @@ snapshots:
kleur@3.0.3: {}
+ leaflet@1.9.4: {}
+
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1