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