feat(grid): add layer preview for geometry columns with Leaflet map (#892)

* feat(grid): add layer preview for geometry columns with Leaflet map

* fix: restore dev:backend script with env vars and cargo watch

* chore: update pnpm-lock.yaml for leaflet and dom-to-image-more dependencies

---------

Co-authored-by: dqn <someonegdeng@qq.com>
This commit is contained in:
DengQingNian
2026-06-09 10:12:56 +08:00
committed by GitHub
co-authored by dqn
parent 65d7a0ab8f
commit 32164d5aea
14 changed files with 943 additions and 1 deletions
+47 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { ref } from "vue";
import { ref, shallowRef } from "vue";
const globalDdlOpen = ref(false);
</script>
@@ -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<HTMLCanvasElement | null>(null);
const dialogGeometryCanvas = ref<HTMLCanvasElement | null>(null);
const previewDialogOpen = ref(false);
const previewDialogConfig = shallowRef<{ component: any; props: Record<string, any> } | null>(null);
const transposeRowIndex = ref<number | null>(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<ContextMenuItem[]>(() => {
// 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;
});
</script>
@@ -8636,6 +8676,12 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
@confirm="confirmDeleteRow"
/>
<ImagePreviewDialog v-model:open="imagePreviewOpen" :src="imagePreviewSrc" :title="imagePreviewTitle" />
<component
v-if="previewDialogOpen && previewDialogConfig"
:is="previewDialogConfig.component"
v-model:open="previewDialogOpen"
v-bind="previewDialogConfig.props"
/>
<ExportProgressDialog v-model:open="exportProgressDialog" v-bind="exportProgressState" disable-cancel />
</div>
</template>
@@ -0,0 +1,584 @@
<script setup lang="ts">
import { computed, ref, watch, onBeforeUnmount, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import { Camera, Loader2, Map, Maximize2, Minimize2, X } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import "leaflet/dist/leaflet.css";
import type L from "leaflet";
const props = defineProps<{
open: boolean;
title?: string;
geojson: string;
}>();
const emit = defineEmits<{
"update:open": [open: boolean];
}>();
const { t } = useI18n();
const mapContainer = ref<HTMLDivElement>();
const mapError = ref<string>("");
const isExporting = ref(false);
// ── Resize / maximise ──────────────────────────────────────────────────────
const customSize = ref<{ w: number; h: number } | null>(null);
const isMaximized = ref(false);
type ResizeEdge = "e" | "s" | "se";
let _resizeEdge: ResizeEdge | null = null;
let _resizeStart: { x: number; y: number; w: number; h: number } | null = null;
const contentStyle = computed(() => {
if (isMaximized.value)
return {
width: "100vw",
height: "100vh",
maxWidth: "none",
maxHeight: "none",
};
if (customSize.value)
return {
width: `${customSize.value.w}px`,
height: `${customSize.value.h}px`,
maxWidth: "none",
maxHeight: "none",
};
return {};
});
function toggleMaximize() {
isMaximized.value = !isMaximized.value;
}
function onResizePointerDown(event: PointerEvent, edge: ResizeEdge) {
const el = (event.currentTarget as HTMLElement).closest("[data-slot='dialog-content']") as HTMLElement;
if (!el) return;
_resizeEdge = edge;
const rect = el.getBoundingClientRect();
_resizeStart = {
x: event.clientX,
y: event.clientY,
w: rect.width,
h: rect.height,
};
(event.target as HTMLElement).setPointerCapture(event.pointerId);
event.preventDefault();
}
function onResizePointerMove(event: PointerEvent) {
if (!_resizeStart || !_resizeEdge) return;
const dx = event.clientX - _resizeStart.x;
const dy = event.clientY - _resizeStart.y;
let w = _resizeStart.w;
let h = _resizeStart.h;
if (_resizeEdge === "e" || _resizeEdge === "se") w += dx;
if (_resizeEdge === "s" || _resizeEdge === "se") h += dy;
w = Math.max(640, Math.min(w, window.innerWidth - 32));
h = Math.max(400, Math.min(h, window.innerHeight - 32));
customSize.value = { w, h };
}
function onResizePointerUp() {
_resizeEdge = null;
_resizeStart = null;
}
// ── Leaflet ───────────────────────────────────────────────────────────────
let _L: typeof L | null = null;
let map: L.Map | null = null;
let geoLayer: L.GeoJSON | null = null;
let labelLayer: L.LayerGroup | null = null;
let tileLayer: L.TileLayer | null = null;
let mapResizeObserver: ResizeObserver | null = null;
let _geojsonData: any = null;
async function loadLeaflet(): Promise<typeof L> {
if (!_L) {
console.log("[LayerPreview] dynamic import leaflet…");
const mod = await import("leaflet");
_L = mod.default as unknown as typeof L;
console.log("[LayerPreview] leaflet loaded, L.version =", (_L as any).version);
}
return _L;
}
interface BasemapOption {
id: string;
label: string;
url: string;
attribution: string;
}
const basemaps: BasemapOption[] = [
{
id: "osm",
label: "OpenStreetMap",
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap</a> contributors',
},
{
id: "satellite",
label: "Satellite",
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
attribution: "&copy; Esri, Maxar, Earthstar Geographics, and the GIS User Community",
},
{
id: "street",
label: "World Street",
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}",
attribution: "&copy; Esri, HERE, Garmin, and the GIS User Community",
},
{
id: "topo",
label: "World Topo",
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}",
attribution: "&copy; Esri, HERE, FAO, NOAA, and the GIS User Community",
},
{
id: "light",
label: "Light",
url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
attribution: '&copy; <a href="https://carto.com/">CARTO</a>',
},
];
const selectedBasemap = ref(basemaps[0]);
const selectedBasemapId = ref(basemaps[0].id);
const dialogTitle = computed(() => props.title || t("grid.layerPreview"));
// ── Label property ─────────────────────────────────────────────────────────
const labelProperties = ref<string[]>([]);
const labelProperty = ref<string>("");
function parseLabelProperties(data: any) {
const keys = new Set<string>();
const features = data.features || (data.type === "Feature" ? [data] : []);
for (const f of features) {
if (f.properties) Object.keys(f.properties).forEach((k) => keys.add(k));
if (keys.size >= 20) break;
}
labelProperties.value = Array.from(keys).sort();
labelProperty.value = "";
}
function onLabelPropertyChange() {
updateLabels();
}
function updateLabels() {
if (!map || !_geojsonData || !_L) return;
// Remove old labels
if (labelLayer) {
map.removeLayer(labelLayer);
labelLayer = null;
}
const prop = labelProperty.value;
if (!prop) return;
const L = _L;
labelLayer = L.layerGroup().addTo(map);
const features = _geojsonData.features || [];
for (const f of features) {
const value = f.properties?.[prop];
if (value == null) continue;
const coords = getLabelCoords(f.geometry);
if (!coords) continue;
const icon = L.divIcon({
className: "layer-preview-label",
html: `<span>${escapeHtml(String(value))}</span>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
});
L.marker(coords, { icon, interactive: false }).addTo(labelLayer);
}
}
function getLabelCoords(geom: any): [number, number] | null {
if (!geom) return null;
if (geom.type === "Point") return [geom.coordinates[1], geom.coordinates[0]];
if (geom.type === "MultiPoint") return [geom.coordinates[0][1], geom.coordinates[0][0]];
// For lines/polygons, label at the centroid (midpoint)
const coords = geom.coordinates?.flat(Infinity).filter((n: any) => typeof n === "number");
if (!coords || coords.length < 2) return null;
// Collect all [lng, lat] pairs
const pts: [number, number][] = [];
const flat = geom.coordinates?.flat(Infinity) ?? [];
for (let i = 0; i + 1 < flat.length; i += 2) {
if (typeof flat[i] === "number" && typeof flat[i + 1] === "number") pts.push([flat[i], flat[i + 1]]);
}
if (pts.length === 0) return null;
const avgLng = pts.reduce((s, p) => s + p[0], 0) / pts.length;
const avgLat = pts.reduce((s, p) => s + p[1], 0) / pts.length;
return [avgLat, avgLng];
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ── Map ────────────────────────────────────────────────────────────────────
function pointToCircleFeature(_feature: any, latlng: L.LatLng): L.CircleMarker {
return _L!.circleMarker(latlng, {
radius: 7,
color: "#ff6600",
weight: 3,
opacity: 0.95,
fillColor: "#ff6600",
fillOpacity: 0.5,
});
}
function popupContent(feature: any): string {
const rows: string[] = [];
if (feature.properties) {
for (const [k, v] of Object.entries(feature.properties)) {
rows.push(`<tr><td class="pr-2 font-medium">${escapeHtml(k)}</td><td>${escapeHtml(String(v ?? ""))}</td></tr>`);
}
}
const geom = feature.geometry?.type ?? "?";
return `<div class="text-xs leading-relaxed"><div class="mb-1 font-semibold text-[#ff6600]">${geom}</div><table>${rows.join("")}</table></div>`;
}
async function initMap() {
const container = mapContainer.value;
if (!container) {
console.warn("[LayerPreview] mapContainer ref is null");
return;
}
cleanupMap();
mapError.value = "";
// Remove the "Loading map…" placeholder so Leaflet can take over.
const placeholder = container.querySelector("[data-map-placeholder]");
if (placeholder) placeholder.remove();
try {
console.log("[LayerPreview] container offset:", container.offsetWidth, "x", container.offsetHeight);
const L = await loadLeaflet();
const rect = container.getBoundingClientRect();
console.log("[LayerPreview] container getBoundingClientRect:", rect.width, "x", rect.height);
map = L.map(container, {
zoomControl: true,
attributionControl: true,
center: [35, 110],
zoom: 4,
});
console.log("[LayerPreview] L.map created");
map.invalidateSize();
tileLayer = L.tileLayer(selectedBasemap.value.url, {
attribution: selectedBasemap.value.attribution,
maxZoom: 19,
crossOrigin: true,
}).addTo(map);
// Sync ID ref
selectedBasemapId.value = selectedBasemap.value.id;
console.log("[LayerPreview] tileLayer added");
addGeoJsonToMap();
parseLabelProperties(_geojsonData);
mapResizeObserver = new ResizeObserver(() => {
map?.invalidateSize();
});
mapResizeObserver.observe(container);
setTimeout(() => {
map?.invalidateSize();
}, 500);
} catch (err) {
console.error("[LayerPreview] initMap error:", err);
mapError.value = String(err);
}
}
function addGeoJsonToMap() {
if (!map || geoLayer) return;
try {
_geojsonData = JSON.parse(props.geojson);
} catch {
console.warn("[LayerPreview] invalid geojson JSON");
return;
}
console.log("[LayerPreview] GeoJSON type:", _geojsonData.type, "features:", _geojsonData.features?.length);
let data = _geojsonData;
if (data.type === "Feature") data = { type: "FeatureCollection", features: [data] };
else if (data.type && data.coordinates)
data = {
type: "FeatureCollection",
features: [{ type: "Feature", geometry: data, properties: {} }],
};
if (!data.features?.length) {
console.warn("[LayerPreview] no features");
return;
}
geoLayer = _L!
.geoJSON(data, {
style: {
color: "#ff6600",
weight: 3,
opacity: 0.95,
fillColor: "#ff6600",
fillOpacity: 0.25,
},
pointToLayer: pointToCircleFeature,
onEachFeature: (feature, layer) => {
layer.bindPopup(popupContent(feature));
},
})
.addTo(map);
const bounds = geoLayer.getBounds();
console.log("[LayerPreview] geoLayer added, bounds:", bounds.toBBoxString(), "valid:", bounds.isValid());
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 18 });
console.log("[LayerPreview] fitBounds done, zoom:", map.getZoom());
}
}
function switchBasemap(basemap: BasemapOption) {
selectedBasemap.value = basemap;
selectedBasemapId.value = basemap.id;
if (!map) return;
const L = _L!;
if (tileLayer) map.removeLayer(tileLayer);
tileLayer = L.tileLayer(basemap.url, {
attribution: basemap.attribution,
maxZoom: 19,
crossOrigin: true,
}).addTo(map);
}
// Watch the dropdown-driven basemap ID and switch when it changes.
watch(selectedBasemapId, (id) => {
const bm = basemaps.find((b) => b.id === id);
if (bm && bm !== selectedBasemap.value) switchBasemap(bm);
});
function cleanupMap() {
mapResizeObserver?.disconnect();
mapResizeObserver = null;
labelLayer = null;
geoLayer = null;
tileLayer = null;
if (map) {
map.remove();
map = null;
}
_geojsonData = null;
labelProperty.value = "";
}
function close() {
emit("update:open", false);
}
async function saveAsImage() {
if (!map || !mapContainer.value || isExporting.value) return;
isExporting.value = true;
try {
const defaultName = `map-${Date.now()}.png`;
const domtoimage = (await import("dom-to-image-more")).default;
const dataUrl = await domtoimage.toPng(mapContainer.value, {
quality: 1,
width: mapContainer.value.offsetWidth,
height: mapContainer.value.offsetHeight,
});
const blob = await (await fetch(dataUrl)).blob();
if (!blob) return;
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeFile } = await import("@tauri-apps/plugin-fs");
const path = await save({
defaultPath: defaultName,
filters: [{ name: "PNG", extensions: ["png"] }],
});
if (!path) return;
await writeFile(path, new Uint8Array(await blob.arrayBuffer()));
} catch {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = defaultName;
a.click();
URL.revokeObjectURL(url);
}
} catch (err) {
console.error("[LayerPreview] saveAsImage error:", err);
} finally {
isExporting.value = false;
}
}
watch(
() => props.open,
async (open) => {
console.log("[LayerPreview] open prop changed:", open);
if (open) {
await nextTick();
if (!mapContainer.value) await nextTick();
console.log("[LayerPreview] after nextTick, mapContainer in DOM:", !!mapContainer.value);
await initMap();
} else {
cleanupMap();
}
},
{ immediate: true },
);
onBeforeUnmount(() => cleanupMap());
</script>
<template>
<Dialog :open="open" @update:open="(value) => emit('update:open', value)">
<DialogContent
:show-close-button="false"
:style="contentStyle"
class="layer-preview-dialog flex w-[96vw] max-w-[1800px] h-[88vh] max-h-[960px] min-w-[640px] min-h-[400px] flex-col gap-0 overflow-hidden rounded-xl border p-0 shadow-2xl"
@escape-key-down="close"
>
<!-- Header -->
<div class="flex h-12 shrink-0 items-center gap-2 border-b bg-muted/20 px-3">
<div class="flex min-w-0 shrink-0 items-center gap-2">
<Map class="h-4 w-4 shrink-0 text-muted-foreground" />
<DialogTitle class="truncate text-sm font-semibold">{{ dialogTitle }}</DialogTitle>
</div>
<!-- Label property selector -->
<select
v-if="labelProperties.length"
v-model="labelProperty"
class="h-6 shrink-0 rounded border bg-background px-1.5 text-[11px] outline-none"
@change="onLabelPropertyChange"
>
<option value="">— 标签 —</option>
<option v-for="p in labelProperties" :key="p" :value="p">
{{ p }}
</option>
</select>
<div class="flex flex-1" />
<!-- Basemap selector -->
<select
v-model="selectedBasemapId"
class="h-6 shrink-0 rounded border bg-background px-1.5 text-[11px] outline-none"
>
<option v-for="bm in basemaps" :key="bm.id" :value="bm.id">
{{ bm.label }}
</option>
</select>
<!-- Save as image -->
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
title="导出图片"
:disabled="isExporting"
@click="saveAsImage"
>
<Camera v-if="!isExporting" class="h-3.5 w-3.5" />
<Loader2 v-else class="h-3.5 w-3.5 animate-spin" />
</Button>
<!-- Maximise -->
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
:title="isMaximized ? '还原' : '最大化'"
@click="toggleMaximize"
>
<Maximize2 v-if="!isMaximized" class="h-3.5 w-3.5" />
<Minimize2 v-else class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
:title="t('dangerDialog.cancel')"
@click="close"
>
<X class="h-3.5 w-3.5" />
</Button>
</div>
<!-- Map -->
<div ref="mapContainer" class="relative w-full flex-1" style="min-height: 200px" data-map-container>
<div
class="absolute inset-0 z-10 flex items-center justify-center text-xs text-muted-foreground pointer-events-none"
data-map-placeholder
>
Loading map…
</div>
</div>
<div
v-if="mapError"
class="absolute inset-0 flex items-center justify-center bg-background/80 p-4 text-sm text-destructive"
>
{{ mapError }}
</div>
<!-- Resize handles -->
<div
class="absolute bottom-0 right-0 z-50 h-5 w-5 cursor-se-resize"
@pointerdown.prevent="onResizePointerDown($event, 'se')"
@pointermove="onResizePointerMove"
@pointerup="onResizePointerUp"
@pointercancel="onResizePointerUp"
>
<div
class="absolute bottom-0.5 right-0.5 h-2.5 w-2.5"
style="border-right: 2px solid; border-bottom: 2px solid; opacity: 0.3"
/>
</div>
<div
class="absolute bottom-0 left-2 right-6 z-50 h-2 cursor-s-resize opacity-0 hover:opacity-25"
@pointerdown.prevent="onResizePointerDown($event, 's')"
@pointermove="onResizePointerMove"
@pointerup="onResizePointerUp"
@pointercancel="onResizePointerUp"
/>
<div
class="absolute right-0 top-2 bottom-6 z-50 w-2 cursor-e-resize opacity-0 hover:opacity-25"
@pointerdown.prevent="onResizePointerDown($event, 'e')"
@pointermove="onResizePointerMove"
@pointerup="onResizePointerUp"
@pointercancel="onResizePointerUp"
/>
</DialogContent>
</Dialog>
</template>
<style>
.layer-preview-dialog .leaflet-container {
width: 100% !important;
height: 100% !important;
background: #1a1a2e;
}
.layer-preview-dialog .leaflet-popup-content-wrapper {
border-radius: 6px;
font-size: 12px;
}
.layer-preview-dialog .leaflet-popup-content {
margin: 8px 12px;
}
.layer-preview-label span {
display: inline-block;
background: rgba(0, 0, 0, 0.65);
color: #fff;
font-size: 10px;
line-height: 1.3;
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
pointer-events: none;
}
</style>
+8
View File
@@ -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<string, any>): Promise<string>;
toBlob(node: HTMLElement, options?: Record<string, any>): Promise<Blob>;
};
export default domtoimage;
}
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -608,6 +608,7 @@ export default {
imagePreview: "图片预览",
imageLoadFailed: "图片加载失败",
geometryPreview: "图形预览",
layerPreview: "图层预览",
zoomIn: "放大",
zoomOut: "缩小",
fitImage: "适应窗口",
+1
View File
@@ -581,6 +581,7 @@ export default {
imagePreview: "圖片預覽",
imageLoadFailed: "圖片載入失敗",
geometryPreview: "圖形預覽",
layerPreview: "圖層預覽",
zoomIn: "放大",
zoomOut: "縮小",
fitImage: "適應視窗",
+117
View File
@@ -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;
}
@@ -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<string, any>[] = [];
const seen = new Set<string>();
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<string, any> = {
_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;
}
@@ -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<string, any>;
}
/**
* 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<QueryResult, "columns" | "column_types">): 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<string, PreviewAction>();
/**
* 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<QueryResult, "columns" | "column_types">): PreviewAction[] {
const applicable: PreviewAction[] = [];
for (const action of actions.values()) {
if (action.isAvailable(result)) {
applicable.push(action);
}
}
return applicable;
}
+3
View File
@@ -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",
+31
View File
@@ -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