feat(grid): add geometry layer preview

This commit is contained in:
amwps290 authored and GitHub committed 2026-08-14 17:47:54 +08:00
1 parent 60e95b1634
commit 0e1022f04c
17 files changed
+519 -37

No files matched your search

+16 -2
View File
@@ -122,7 +122,7 @@ import {
} from "@/lib/dataGrid/dataGridTranspose";
import { canApplyGridSelectionValue, canDeleteGridRowItem, canEditGridCellDetail, matchesRowStatusFilter, shouldShowQuickEntryDraftRow, type RowStatus, type RowStatusFilter } from "@/lib/dataGrid/gridRowStatus";
import { displayCellValue, firstLineCellDisplayValue, limitDataGridCellDisplay, SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH, type CellValue } from "@/lib/dataGrid/cellValue";
import { getApplicablePreviewActions } from "@/lib/dataGrid/resultPreviewRegistry";
import { getApplicablePreviewActions, type PreviewAction } from "@/lib/dataGrid/resultPreviewRegistry";
import "@/lib/dataGrid/geometryMapPreview";
import {
BINARY_CELL_DOWNLOAD_MODES,
@@ -3781,6 +3781,17 @@ const previewToolbarCapability = computed<DataGridToolbarActionCapability>(() =>
loading: isPreviewLoading.value,
onTrigger: openSqlPreview,
}));
const layerPreviewToolbarCapability = computed<DataGridToolbarActionCapability>(() => {
const action = previewActions.value.find((candidate) => candidate.id === "geometry-map-preview");
return {
label: t("grid.layerPreview"),
visible: !!action,
disabled: props.loading || !action,
onTrigger: () => {
if (action) executePreviewAction(action);
},
};
});
const saveToolbarCapability = computed<DataGridToolbarSaveCapability>(() => ({
label: t(saveActionMode.value.labelKey, { count: pendingChangeCount.value }),
tooltip: t(saveActionMode.value.tooltipKey, {
@@ -4596,7 +4607,7 @@ function exportSelectedRowsTxt() {
return exportTxt(rowIds);
}
function executePreviewAction(action: { execute: (ctx: any) => any }) {
function executePreviewAction(action: PreviewAction) {
const config = action.execute({
result: props.result,
selectedRowIds: affectedRowIds(),
@@ -4605,6 +4616,8 @@ function executePreviewAction(action: { execute: (ctx: any) => any }) {
if (config) {
previewDialogConfig.value = config;
previewDialogOpen.value = true;
} else if (action.id === "geometry-map-preview") {
toast(t("grid.layerPreviewNoGeometryData"), 3000);
}
}
@@ -10060,6 +10073,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:auto-refresh="autoRefreshToolbarCapability"
:add-row="addRowToolbarCapability"
:delete-row="deleteRowToolbarCapability"
:layer-preview="layerPreviewToolbarCapability"
:preview="previewToolbarCapability"
:save="saveToolbarCapability"
:rollback="rollbackToolbarCapability"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from "vue";
import { Check, ChevronDown, Copy, Download, Eye, Loader2, Plus, RefreshCcw, RotateCcw, Rows3, Save, TableProperties, Timer, Trash2 } from "@lucide/vue";
import { Check, ChevronDown, Copy, Download, Eye, Loader2, Map, Plus, RefreshCcw, RotateCcw, Rows3, Save, TableProperties, Timer, Trash2 } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -33,6 +33,7 @@ const props = defineProps<{
exportData?: DataGridToolbarExportCapability;
transpose?: DataGridToolbarActionCapability;
tableInfo?: DataGridToolbarActionCapability;
layerPreview?: DataGridToolbarActionCapability;
preview?: DataGridToolbarActionCapability;
save?: DataGridToolbarSaveCapability;
rollback?: DataGridToolbarActionCapability;
@@ -201,6 +202,16 @@ function actionLabelClass() {
<TooltipContent side="bottom">{{ tableInfo?.tooltip ?? tableInfo?.label }}</TooltipContent>
</Tooltip>
<Tooltip v-if="isDataGridToolbarCapabilityVisible(layerPreview)">
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" :class="actionButtonClass" :disabled="isDataGridToolbarCapabilityDisabled(layerPreview)" @click="void triggerDataGridToolbarAction(layerPreview)">
<Map class="data-grid-topbar-action-icon h-3 w-3" />
<span class="data-grid-topbar-action-label" :class="actionLabelClass()">{{ layerPreview?.label }}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{{ layerPreview?.tooltip ?? layerPreview?.label }}</TooltipContent>
</Tooltip>
<Tooltip v-if="isDataGridToolbarCapabilityVisible(preview)">
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" :class="[...actionButtonClass, 'text-sky-600 hover:bg-sky-500/10 hover:text-sky-700']" :disabled="isDataGridToolbarCapabilityDisabled(preview)" @click="void triggerDataGridToolbarAction(preview)">
@@ -4,9 +4,11 @@ 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 { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import { renderGeometryFeaturesIndependently } from "@/lib/dataGrid/geometryLayerPreview";
import { prepareCoordsToLatLng, type CoordinateToLatLng } from "@/lib/dataGrid/geometryProjection";
import { getSpatialReference, SHORTLIST_SRIDS } from "@/lib/dataGrid/spatialReferenceCatalog";
import { escapeCustomBasemapAttribution, loadCustomBasemapConfig, loadSelectedBasemapId, normalizeCustomBasemapConfig, resolveSelectedBasemapId, saveCustomBasemapConfig, saveSelectedBasemapId, type CustomBasemapConfig } from "@/lib/dataGrid/customBasemap";
import type { GeoJsonGeometry } from "@/lib/dataGrid/geometryPreview";
import "leaflet/dist/leaflet.css";
import type L from "leaflet";
@@ -110,6 +112,7 @@ let map: L.Map | null = null;
let geoLayer: L.FeatureGroup | null = null;
let labelLayer: L.LayerGroup | null = null;
let tileLayer: L.TileLayer | null = null;
let overlayTileLayer: L.TileLayer | null = null;
let mapResizeObserver: ResizeObserver | null = null;
let _geojsonData: any = null;
let mapGeneration = 0;
@@ -144,6 +147,9 @@ interface BasemapOption {
label: string;
url: string;
attribution: string;
overlayUrl?: string;
maxZoom?: number;
custom?: boolean;
}
const basemaps: BasemapOption[] = [
@@ -179,8 +185,45 @@ const basemaps: BasemapOption[] = [
},
];
const selectedBasemap = ref(basemaps[0]);
const selectedBasemapId = ref(basemaps[0].id);
function sessionStorageOrNull(): Storage | null {
try {
return globalThis.sessionStorage ?? null;
} catch {
return null;
}
}
function customBasemapOption(config: CustomBasemapConfig): BasemapOption {
return {
id: "custom",
label: config.name,
url: config.url,
overlayUrl: config.overlayUrl || undefined,
attribution: config.attribution,
maxZoom: config.maxZoom,
custom: true,
};
}
const basemapStorage = sessionStorageOrNull();
const storedCustomBasemap = loadCustomBasemapConfig(basemapStorage);
const storedSelectedBasemapId = loadSelectedBasemapId(basemapStorage);
const initialBasemapId = resolveSelectedBasemapId(
storedSelectedBasemapId,
basemaps.map((basemap) => basemap.id),
storedCustomBasemap !== null,
);
const initialBasemap = initialBasemapId === "custom" && storedCustomBasemap ? customBasemapOption(storedCustomBasemap) : (basemaps.find((basemap) => basemap.id === initialBasemapId) ?? basemaps[0]);
const customBasemapConfig = ref<CustomBasemapConfig | null>(storedCustomBasemap);
const selectedBasemap = ref<BasemapOption>(initialBasemap);
const selectedBasemapId = ref(selectedBasemap.value.id);
const customBasemapOpen = ref(false);
const customBasemapName = ref(storedCustomBasemap?.name ?? "");
const customBasemapUrl = ref(storedCustomBasemap?.url ?? "");
const customBasemapOverlayUrl = ref(storedCustomBasemap?.overlayUrl ?? "");
const customBasemapAttribution = ref(storedCustomBasemap?.attribution ?? "");
const customBasemapMaxZoom = ref(String(storedCustomBasemap?.maxZoom ?? 18));
const customBasemapError = ref("");
const dialogTitle = computed(() => props.title || t("grid.layerPreview"));
// ── Label property ─────────────────────────────────────────────────────────
@@ -323,11 +366,7 @@ async function initMap() {
});
map.invalidateSize();
tileLayer = L.tileLayer(selectedBasemap.value.url, {
attribution: selectedBasemap.value.attribution,
maxZoom: 19,
crossOrigin: true,
}).addTo(map);
addBasemapLayers(selectedBasemap.value);
// Sync ID ref
selectedBasemapId.value = selectedBasemap.value.id;
await addGeoJsonToMap(generation);
@@ -487,21 +526,74 @@ function onCustomSridSubmit() {
function switchBasemap(basemap: BasemapOption) {
selectedBasemap.value = basemap;
selectedBasemapId.value = basemap.id;
saveSelectedBasemapId(basemapStorage, 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);
removeBasemapLayers();
addBasemapLayers(basemap);
}
// 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 basemapAttribution(basemap: BasemapOption): string {
return basemap.custom ? escapeCustomBasemapAttribution(basemap.attribution) : basemap.attribution;
}
function addBasemapLayers(basemap: BasemapOption) {
if (!map || !_L) return;
const options = {
attribution: basemapAttribution(basemap),
maxZoom: basemap.maxZoom ?? 19,
crossOrigin: true,
};
tileLayer = _L.tileLayer(basemap.url, options).addTo(map);
if (basemap.overlayUrl) overlayTileLayer = _L.tileLayer(basemap.overlayUrl, options).addTo(map);
}
function removeBasemapLayers() {
if (!map) return;
if (tileLayer) map.removeLayer(tileLayer);
if (overlayTileLayer) map.removeLayer(overlayTileLayer);
tileLayer = null;
overlayTileLayer = null;
}
function openCustomBasemapEditor() {
const current = customBasemapConfig.value;
customBasemapName.value = current?.name ?? customBasemapName.value;
customBasemapUrl.value = current?.url ?? customBasemapUrl.value;
customBasemapOverlayUrl.value = current?.overlayUrl ?? customBasemapOverlayUrl.value;
customBasemapAttribution.value = current?.attribution ?? customBasemapAttribution.value;
customBasemapMaxZoom.value = String(current?.maxZoom ?? (customBasemapMaxZoom.value || 18));
customBasemapError.value = "";
customBasemapOpen.value = true;
}
function onBasemapSelect(id: string) {
if (id === "custom-editor") {
selectedBasemapId.value = selectedBasemap.value.id;
openCustomBasemapEditor();
return;
}
const basemap = id === "custom" && customBasemapConfig.value ? customBasemapOption(customBasemapConfig.value) : basemaps.find((candidate) => candidate.id === id);
if (basemap) switchBasemap(basemap);
}
function applyCustomBasemap() {
const config = normalizeCustomBasemapConfig({
name: customBasemapName.value,
url: customBasemapUrl.value,
overlayUrl: customBasemapOverlayUrl.value,
attribution: customBasemapAttribution.value,
maxZoom: Number(customBasemapMaxZoom.value),
});
if (!config) {
customBasemapError.value = t("grid.layerPreviewCustomBasemapInvalid");
return;
}
saveCustomBasemapConfig(basemapStorage, config);
customBasemapConfig.value = config;
switchBasemap(customBasemapOption(config));
customBasemapOpen.value = false;
customBasemapError.value = "";
}
function cleanupMap() {
mapGeneration++;
@@ -510,6 +602,7 @@ function cleanupMap() {
labelLayer = null;
geoLayer = null;
tileLayer = null;
overlayTileLayer = null;
if (map) {
map.remove();
map = null;
@@ -617,11 +710,50 @@ onBeforeUnmount(() => cleanupMap());
</select>
<!-- Basemap selector -->
<select v-model="selectedBasemapId" class="ml-auto 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>
<Popover v-model:open="customBasemapOpen">
<PopoverAnchor as-child>
<select class="ml-auto h-6 shrink-0 rounded border bg-background px-1.5 text-[11px] outline-none" :value="selectedBasemapId" @change="onBasemapSelect(($event.target as HTMLSelectElement).value)">
<option v-for="bm in basemaps" :key="bm.id" :value="bm.id">
{{ bm.label }}
</option>
<option v-if="customBasemapConfig" value="custom">{{ customBasemapConfig.name }}</option>
<option value="custom-editor">{{ t("grid.layerPreviewCustomBasemapAction") }}</option>
</select>
</PopoverAnchor>
<PopoverContent align="end" class="w-[26rem] space-y-3 p-3">
<div>
<div class="text-sm font-medium">{{ t("grid.layerPreviewCustomBasemapTitle") }}</div>
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("grid.layerPreviewCustomBasemapHint") }}</p>
</div>
<label class="block space-y-1">
<span class="text-xs font-medium">{{ t("grid.layerPreviewCustomBasemapName") }}</span>
<input v-model="customBasemapName" maxlength="50" class="h-8 w-full rounded border bg-background px-2 text-xs outline-none focus-visible:border-ring" :placeholder="t('grid.layerPreviewCustomBasemapNamePlaceholder')" />
</label>
<label class="block space-y-1">
<span class="text-xs font-medium">{{ t("grid.layerPreviewCustomBasemapUrl") }}</span>
<input v-model="customBasemapUrl" type="url" spellcheck="false" class="h-8 w-full rounded border bg-background px-2 font-mono text-xs outline-none focus-visible:border-ring" placeholder="https://tiles.example.com/{z}/{x}/{y}.png" />
</label>
<label class="block space-y-1">
<span class="text-xs font-medium">{{ t("grid.layerPreviewCustomBasemapOverlayUrl") }}</span>
<input v-model="customBasemapOverlayUrl" type="url" spellcheck="false" class="h-8 w-full rounded border bg-background px-2 font-mono text-xs outline-none focus-visible:border-ring" :placeholder="t('grid.layerPreviewCustomBasemapOptional')" />
</label>
<div class="grid grid-cols-[minmax(0,1fr)_6rem] gap-2">
<label class="block space-y-1">
<span class="text-xs font-medium">{{ t("grid.layerPreviewCustomBasemapAttribution") }}</span>
<input v-model="customBasemapAttribution" class="h-8 w-full rounded border bg-background px-2 text-xs outline-none focus-visible:border-ring" :placeholder="t('grid.layerPreviewCustomBasemapOptional')" />
</label>
<label class="block space-y-1">
<span class="text-xs font-medium">{{ t("grid.layerPreviewCustomBasemapMaxZoom") }}</span>
<input v-model="customBasemapMaxZoom" type="number" min="1" max="24" class="h-8 w-full rounded border bg-background px-2 text-xs outline-none focus-visible:border-ring" />
</label>
</div>
<p v-if="customBasemapError" class="text-xs text-destructive">{{ customBasemapError }}</p>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="sm" class="h-7" @click="customBasemapOpen = false">{{ t("dangerDialog.cancel") }}</Button>
<Button size="sm" class="h-7" @click="applyCustomBasemap">{{ t("grid.layerPreviewCustomBasemapApply") }}</Button>
</div>
</PopoverContent>
</Popover>
<!-- 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="t('grid.layerPreviewExport')" :disabled="isExporting" @click="saveAsImage">
+13
View File
@@ -1833,6 +1833,19 @@ export default {
layerPreviewCrsManual: "Coordinate system: manual",
layerPreviewCrsDetected: "Coordinate system: detected",
layerPreviewCrsAssumed: "Coordinate system: assumed WGS 84",
layerPreviewCustomBasemap: "Custom basemap",
layerPreviewCustomBasemapAction: "Custom basemap…",
layerPreviewCustomBasemapTitle: "Custom basemap",
layerPreviewCustomBasemapName: "Name",
layerPreviewCustomBasemapNamePlaceholder: "e.g. Gaode streets",
layerPreviewCustomBasemapHint: "Enter an XYZ tile URL using {z}, {x}, and {y}. An optional overlay URL is useful for labels, such as Tianditu annotations.",
layerPreviewCustomBasemapUrl: "Tile URL",
layerPreviewCustomBasemapOverlayUrl: "Overlay URL",
layerPreviewCustomBasemapAttribution: "Attribution",
layerPreviewCustomBasemapMaxZoom: "Max zoom",
layerPreviewCustomBasemapOptional: "Optional",
layerPreviewCustomBasemapApply: "Apply",
layerPreviewCustomBasemapInvalid: "Enter a name and a valid HTTP(S) XYZ tile URL containing {z}, {x}, and {y}.",
insertRowsNotSupported: "The current save target does not support adding rows.",
zoomIn: "Zoom In",
zoomOut: "Zoom Out",
+13
View File
@@ -1700,6 +1700,19 @@ export default withEnglishFallback({
layerPreviewCrsManual: "Sistema de coordenadas: manual",
layerPreviewCrsDetected: "Sistema de coordenadas: detectado",
layerPreviewCrsAssumed: "Sistema de coordenadas: se asume WGS 84",
layerPreviewCustomBasemap: "Mapa base personalizado",
layerPreviewCustomBasemapAction: "Mapa base personalizado…",
layerPreviewCustomBasemapTitle: "Mapa base personalizado",
layerPreviewCustomBasemapName: "Nombre",
layerPreviewCustomBasemapNamePlaceholder: "p. ej., calles de Gaode",
layerPreviewCustomBasemapHint: "Introduzca una URL de teselas XYZ con {z}, {x} y {y}. La superposición opcional sirve para etiquetas.",
layerPreviewCustomBasemapUrl: "URL de teselas",
layerPreviewCustomBasemapOverlayUrl: "URL de superposición",
layerPreviewCustomBasemapAttribution: "Atribución",
layerPreviewCustomBasemapMaxZoom: "Zoom máximo",
layerPreviewCustomBasemapOptional: "Opcional",
layerPreviewCustomBasemapApply: "Aplicar",
layerPreviewCustomBasemapInvalid: "Introduzca un nombre y una URL XYZ HTTP(S) válida con los marcadores {z}, {x} y {y}.",
insertRowsNotSupported: "El destino de guardado actual no admite añadir filas.",
zoomIn: "Acercar",
zoomOut: "Alejar",
+13
View File
@@ -1698,6 +1698,19 @@ export default withEnglishFallback({
layerPreviewCrsManual: "Sistema di coordinate: manuale",
layerPreviewCrsDetected: "Sistema di coordinate: rilevato",
layerPreviewCrsAssumed: "Sistema di coordinate: presunto WGS 84",
layerPreviewCustomBasemap: "Basemap personalizzata",
layerPreviewCustomBasemapAction: "Basemap personalizzata…",
layerPreviewCustomBasemapTitle: "Basemap personalizzata",
layerPreviewCustomBasemapName: "Nome",
layerPreviewCustomBasemapNamePlaceholder: "es. strade Gaode",
layerPreviewCustomBasemapHint: "Inserisci un URL di tile XYZ con {z}, {x} e {y}. L'overlay opzionale è utile per le etichette.",
layerPreviewCustomBasemapUrl: "URL tile",
layerPreviewCustomBasemapOverlayUrl: "URL overlay",
layerPreviewCustomBasemapAttribution: "Attribuzione",
layerPreviewCustomBasemapMaxZoom: "Zoom massimo",
layerPreviewCustomBasemapOptional: "Facoltativo",
layerPreviewCustomBasemapApply: "Applica",
layerPreviewCustomBasemapInvalid: "Inserisci un nome e un URL tile XYZ HTTP(S) valido con i segnaposto {z}, {x} e {y}.",
insertRowsNotSupported: "La destinazione di salvataggio corrente non supporta l'aggiunta di righe.",
zoomIn: "Ingrandisci",
zoomOut: "Rimpicciolisci",
+13
View File
@@ -1716,6 +1716,19 @@ export default withEnglishFallback({
layerPreviewCrsManual: "座標系: 手動",
layerPreviewCrsDetected: "座標系: 検出済み",
layerPreviewCrsAssumed: "座標系: 既定の WGS 84",
layerPreviewCustomBasemap: "カスタムベースマップ",
layerPreviewCustomBasemapAction: "カスタムベースマップ…",
layerPreviewCustomBasemapTitle: "カスタムベースマップ",
layerPreviewCustomBasemapName: "名前",
layerPreviewCustomBasemapNamePlaceholder: "例: 高徳ストリート",
layerPreviewCustomBasemapHint: "{z}、{x}、{y} を含む XYZ タイル URL を入力してください。オプションのオーバーレイ URL はラベルに使用できます。",
layerPreviewCustomBasemapUrl: "タイル URL",
layerPreviewCustomBasemapOverlayUrl: "オーバーレイ URL",
layerPreviewCustomBasemapAttribution: "帰属表示",
layerPreviewCustomBasemapMaxZoom: "最大ズーム",
layerPreviewCustomBasemapOptional: "任意",
layerPreviewCustomBasemapApply: "適用",
layerPreviewCustomBasemapInvalid: "名前と、{z}、{x}、{y} を含む有効な HTTP(S) XYZ タイル URL を入力してください。",
insertRowsNotSupported: "現在の保存先は行の追加に対応していません。",
zoomIn: "拡大",
zoomOut: "縮小",
+13
View File
@@ -1700,6 +1700,19 @@ export default withEnglishFallback({
layerPreviewExportImage: "이미지 내보내기",
layerPreviewMaximize: "최대화",
layerPreviewRestore: "복원",
layerPreviewCustomBasemap: "사용자 지정 기본 지도",
layerPreviewCustomBasemapAction: "사용자 지정 기본 지도…",
layerPreviewCustomBasemapTitle: "사용자 지정 기본 지도",
layerPreviewCustomBasemapName: "이름",
layerPreviewCustomBasemapNamePlaceholder: "예: Gaode 거리",
layerPreviewCustomBasemapHint: "{z}, {x}, {y}가 포함된 XYZ 타일 URL을 입력하세요. 선택적 오버레이 URL은 라벨에 사용할 수 있습니다.",
layerPreviewCustomBasemapUrl: "타일 URL",
layerPreviewCustomBasemapOverlayUrl: "오버레이 URL",
layerPreviewCustomBasemapAttribution: "저작권 표시",
layerPreviewCustomBasemapMaxZoom: "최대 확대",
layerPreviewCustomBasemapOptional: "선택 사항",
layerPreviewCustomBasemapApply: "적용",
layerPreviewCustomBasemapInvalid: "이름과 {z}, {x}, {y}가 포함된 유효한 HTTP(S) XYZ 타일 URL을 입력하세요.",
insertRowsNotSupported: "현재 저장 대상은 행 추가를 지원하지 않습니다.",
zoomIn: "확대",
zoomOut: "축소",
+13
View File
@@ -1700,6 +1700,19 @@ export default withEnglishFallback({
layerPreviewCrsManual: "Sistema de coordenadas: manual",
layerPreviewCrsDetected: "Sistema de coordenadas: detectado",
layerPreviewCrsAssumed: "Sistema de coordenadas: presumido WGS 84",
layerPreviewCustomBasemap: "Mapa base personalizado",
layerPreviewCustomBasemapAction: "Mapa base personalizado…",
layerPreviewCustomBasemapTitle: "Mapa base personalizado",
layerPreviewCustomBasemapName: "Nome",
layerPreviewCustomBasemapNamePlaceholder: "ex.: ruas Gaode",
layerPreviewCustomBasemapHint: "Informe uma URL de tiles XYZ com {z}, {x} e {y}. Uma sobreposição opcional pode ser usada para rótulos.",
layerPreviewCustomBasemapUrl: "URL dos tiles",
layerPreviewCustomBasemapOverlayUrl: "URL da sobreposição",
layerPreviewCustomBasemapAttribution: "Atribuição",
layerPreviewCustomBasemapMaxZoom: "Zoom máximo",
layerPreviewCustomBasemapOptional: "Opcional",
layerPreviewCustomBasemapApply: "Aplicar",
layerPreviewCustomBasemapInvalid: "Informe um nome e uma URL XYZ HTTP(S) válida contendo os marcadores {z}, {x} e {y}.",
insertRowsNotSupported: "O destino de salvamento atual não suporta a adição de linhas.",
zoomIn: "Aproximar",
zoomOut: "Afastar",
+13
View File
@@ -1758,6 +1758,19 @@ export default withEnglishFallback({
layerPreviewCrsManual: "坐标系:手动",
layerPreviewCrsDetected: "坐标系:已探测",
layerPreviewCrsAssumed: "坐标系:默认 WGS 84",
layerPreviewCustomBasemap: "自定义底图",
layerPreviewCustomBasemapAction: "自定义底图…",
layerPreviewCustomBasemapTitle: "自定义底图",
layerPreviewCustomBasemapName: "名称",
layerPreviewCustomBasemapNamePlaceholder: "例如:高德街道",
layerPreviewCustomBasemapHint: "请输入包含 {z}、{x} 和 {y} 的 XYZ 瓦片地址。可选叠加地址适合天地图标注图层。",
layerPreviewCustomBasemapUrl: "瓦片地址",
layerPreviewCustomBasemapOverlayUrl: "叠加图层地址",
layerPreviewCustomBasemapAttribution: "版权归属",
layerPreviewCustomBasemapMaxZoom: "最大缩放",
layerPreviewCustomBasemapOptional: "可选",
layerPreviewCustomBasemapApply: "应用",
layerPreviewCustomBasemapInvalid: "请输入名称和有效的 HTTP(S) XYZ 瓦片地址,并包含 {z}、{x}、{y} 占位符。",
insertRowsNotSupported: "当前保存目标不支持新增行。",
zoomIn: "放大",
zoomOut: "缩小",
+13
View File
@@ -1700,6 +1700,19 @@ export default withEnglishFallback({
layerPreviewCrsManual: "座標系:手動",
layerPreviewCrsDetected: "座標系:已偵測",
layerPreviewCrsAssumed: "座標系:預設 WGS 84",
layerPreviewCustomBasemap: "自訂底圖",
layerPreviewCustomBasemapAction: "自訂底圖…",
layerPreviewCustomBasemapTitle: "自訂底圖",
layerPreviewCustomBasemapName: "名稱",
layerPreviewCustomBasemapNamePlaceholder: "例如:高德街道",
layerPreviewCustomBasemapHint: "請輸入包含 {z}、{x} 和 {y} 的 XYZ 圖磚位址。可選疊加位址適合天地圖標註圖層。",
layerPreviewCustomBasemapUrl: "圖磚位址",
layerPreviewCustomBasemapOverlayUrl: "疊加圖層位址",
layerPreviewCustomBasemapAttribution: "版權歸屬",
layerPreviewCustomBasemapMaxZoom: "最大縮放",
layerPreviewCustomBasemapOptional: "選填",
layerPreviewCustomBasemapApply: "套用",
layerPreviewCustomBasemapInvalid: "請輸入名稱和有效的 HTTP(S) XYZ 圖磚位址,並包含 {z}、{x}、{y} 佔位符。",
insertRowsNotSupported: "目前的儲存目標不支援新增資料列。",
zoomIn: "放大",
zoomOut: "縮小",
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { escapeCustomBasemapAttribution, loadCustomBasemapConfig, loadSelectedBasemapId, normalizeCustomBasemapConfig, resolveSelectedBasemapId, saveCustomBasemapConfig, saveSelectedBasemapId } from "@/lib/dataGrid/customBasemap";
const layerPreviewSource = readFileSync(new URL("../../../components/grid/LayerPreviewDialog.vue", import.meta.url), "utf8");
function storage() {
const values = new Map<string, string>();
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
};
}
describe("custom basemap configuration", () => {
it("keeps a saved custom basemap available after switching to a built-in basemap", () => {
expect(layerPreviewSource).toMatch(/<option v-if="customBasemapConfig" value="custom">/);
expect(layerPreviewSource).toMatch(/id === "custom" && customBasemapConfig\.value/);
expect(layerPreviewSource).toMatch(/v-model="customBasemapName"/);
});
it("normalizes a valid XYZ configuration", () => {
expect(
normalizeCustomBasemapConfig({
name: " Gaode streets ",
url: " https://a.example/{z}/{x}/{y}.png ",
overlayUrl: "https://b.example/{z}/{x}/{y}.png",
attribution: " Example ",
maxZoom: 30.2,
}),
).toEqual({
name: "Gaode streets",
url: "https://a.example/{z}/{x}/{y}.png",
overlayUrl: "https://b.example/{z}/{x}/{y}.png",
attribution: "Example",
maxZoom: 24,
});
});
it("rejects invalid protocols and incomplete templates", () => {
expect(normalizeCustomBasemapConfig({ name: "Unsafe", url: "javascript:alert(1)" })).toBeNull();
expect(normalizeCustomBasemapConfig({ name: "Incomplete", url: "https://tiles.example/{z}/{x}.png" })).toBeNull();
expect(normalizeCustomBasemapConfig({ name: "Overlay", url: "https://tiles.example/{z}/{x}/{y}.png", overlayUrl: "not a url" })).toBeNull();
expect(normalizeCustomBasemapConfig({ name: "", url: "https://tiles.example/{z}/{x}/{y}.png" })).toBeNull();
});
it("round-trips through session storage", () => {
const target = storage();
const config = { name: "My tiles", url: "https://tiles.example/{z}/{x}/{y}.png", overlayUrl: "", attribution: "Tiles", maxZoom: 18 };
expect(saveCustomBasemapConfig(target, config)).toBe(true);
expect(loadCustomBasemapConfig(target)).toEqual(config);
});
it("treats custom attribution as plain text", () => {
const malicious = `<img src=x onerror="alert(1)"><svg onload='alert(2)'><script>alert(3)</script>&`;
const escaped = escapeCustomBasemapAttribution(malicious);
expect(escaped).toBe("&lt;img src=x onerror=&quot;alert(1)&quot;&gt;&lt;svg onload=&#39;alert(2)&#39;&gt;&lt;script&gt;alert(3)&lt;/script&gt;&amp;");
expect(escaped).not.toMatch(/<(?:img|svg|script)\b/i);
expect(layerPreviewSource).toMatch(/basemap\.custom \? escapeCustomBasemapAttribution\(basemap\.attribution\) : basemap\.attribution/);
});
it("persists the actual basemap selection separately from the custom configuration", () => {
const target = storage();
expect(saveSelectedBasemapId(target, "custom")).toBe(true);
expect(loadSelectedBasemapId(target)).toBe("custom");
expect(saveSelectedBasemapId(target, "osm")).toBe(true);
expect(loadSelectedBasemapId(target)).toBe("osm");
expect(layerPreviewSource).toMatch(/saveSelectedBasemapId\(basemapStorage, basemap\.id\)/);
});
it("does not enable a stored custom basemap unless it was selected", () => {
const builtInIds = ["osm", "satellite"];
expect(resolveSelectedBasemapId(null, builtInIds, true)).toBe("osm");
expect(resolveSelectedBasemapId("osm", builtInIds, true)).toBe("osm");
expect(resolveSelectedBasemapId("custom", builtInIds, true)).toBe("custom");
expect(resolveSelectedBasemapId("custom", builtInIds, false)).toBe("osm");
});
it("restores legacy session data with a fallback name", () => {
const target = storage();
target.setItem("dbx-layer-preview-custom-basemap", JSON.stringify({ url: "https://tiles.example/{z}/{x}/{y}.png", overlayUrl: "", attribution: "Tiles", maxZoom: 18 }));
expect(loadCustomBasemapConfig(target)?.name).toBe("Custom basemap");
});
});
@@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const toolbarSource = readFileSync(new URL("../../../components/grid/DataGridToolbar.vue", import.meta.url), "utf8");
const dataGridSource = readFileSync(new URL("../../../components/grid/DataGrid.vue", import.meta.url), "utf8");
describe("DataGrid geometry layer preview toolbar entry", () => {
it("opens the registered geometry preview action from a visible map button", () => {
expect(toolbarSource).toMatch(/layerPreview\?: DataGridToolbarActionCapability/);
expect(toolbarSource).toMatch(/isDataGridToolbarCapabilityVisible\(layerPreview\)/);
expect(toolbarSource).toMatch(/triggerDataGridToolbarAction\(layerPreview\)/);
expect(toolbarSource).toMatch(/<Map class="data-grid-topbar-action-icon/);
expect(dataGridSource).toMatch(/candidate\.id === "geometry-map-preview"/);
expect(dataGridSource).toMatch(/:layer-preview="layerPreviewToolbarCapability"/);
expect(dataGridSource).toMatch(/executePreviewAction\(action\)/);
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildGeometryMapFeatureCollection } from "@/lib/dataGrid/geometryMapPreview";
import { buildGeometryMapFeatureCollection, hasGeometryMapPreviewColumns } from "@/lib/dataGrid/geometryMapPreview";
import type { PreviewActionContext } from "@/lib/dataGrid/resultPreviewRegistry";
function context(): PreviewActionContext {
@@ -29,6 +29,21 @@ function context(): PreviewActionContext {
}
describe("buildGeometryMapFeatureCollection", () => {
it("checks preview availability from column metadata without parsing result rows", () => {
const result = context().result;
expect(hasGeometryMapPreviewColumns(result)).toBe(true);
result.rows = [
[null, "empty"],
["0x0101000000", "binary"],
["not geometry", "invalid"],
];
expect(hasGeometryMapPreviewColumns(result)).toBe(true);
result.column_types = ["text", "text"];
expect(hasGeometryMapPreviewColumns(result)).toBe(false);
});
it("exposes the detected layer SRID and binds row properties by sourceIndex", () => {
const collection = buildGeometryMapFeatureCollection(context());
@@ -0,0 +1,103 @@
export interface CustomBasemapConfig {
name: string;
url: string;
overlayUrl: string;
attribution: string;
maxZoom: number;
}
export const CUSTOM_BASEMAP_SESSION_KEY = "dbx-layer-preview-custom-basemap";
export const BASEMAP_SELECTION_SESSION_KEY = "dbx-layer-preview-selected-basemap";
export const DEFAULT_CUSTOM_BASEMAP_NAME = "Custom basemap";
const BASEMAP_ID_PATTERN = /^[a-z0-9-]{1,50}$/;
export function escapeCustomBasemapAttribution(value: string): string {
return value.replace(/[&<>"']/g, (character) => {
switch (character) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
default:
return "&#39;";
}
});
}
export function isValidTileUrlTemplate(value: string): boolean {
const trimmed = value.trim();
if (!/^https?:\/\/\S+$/i.test(trimmed)) return false;
return ["{z}", "{x}", "{y}"].every((token) => trimmed.includes(token));
}
export function normalizeCustomBasemapConfig(value: unknown): CustomBasemapConfig | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Partial<CustomBasemapConfig>;
const name = typeof candidate.name === "string" ? candidate.name.trim().slice(0, 50) : "";
const url = typeof candidate.url === "string" ? candidate.url.trim() : "";
const overlayUrl = typeof candidate.overlayUrl === "string" ? candidate.overlayUrl.trim() : "";
const attribution = typeof candidate.attribution === "string" ? candidate.attribution.trim() : "";
const maxZoom = typeof candidate.maxZoom === "number" && Number.isFinite(candidate.maxZoom) ? Math.round(candidate.maxZoom) : 18;
if (!name || !isValidTileUrlTemplate(url) || (overlayUrl && !isValidTileUrlTemplate(overlayUrl))) return null;
return {
name,
url,
overlayUrl,
attribution,
maxZoom: Math.min(24, Math.max(1, maxZoom)),
};
}
export function loadCustomBasemapConfig(storage: Pick<Storage, "getItem"> | null | undefined): CustomBasemapConfig | null {
if (!storage) return null;
try {
const raw = storage.getItem(CUSTOM_BASEMAP_SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && !("name" in parsed)) parsed.name = DEFAULT_CUSTOM_BASEMAP_NAME;
return normalizeCustomBasemapConfig(parsed);
} catch {
return null;
}
}
export function saveCustomBasemapConfig(storage: Pick<Storage, "setItem"> | null | undefined, config: CustomBasemapConfig): boolean {
if (!storage) return false;
try {
storage.setItem(CUSTOM_BASEMAP_SESSION_KEY, JSON.stringify(config));
return true;
} catch {
return false;
}
}
export function loadSelectedBasemapId(storage: Pick<Storage, "getItem"> | null | undefined): string | null {
if (!storage) return null;
try {
const id = storage.getItem(BASEMAP_SELECTION_SESSION_KEY);
return id && BASEMAP_ID_PATTERN.test(id) ? id : null;
} catch {
return null;
}
}
export function saveSelectedBasemapId(storage: Pick<Storage, "setItem"> | null | undefined, id: string): boolean {
if (!storage || !BASEMAP_ID_PATTERN.test(id)) return false;
try {
storage.setItem(BASEMAP_SELECTION_SESSION_KEY, id);
return true;
} catch {
return false;
}
}
export function resolveSelectedBasemapId(selectedId: string | null, builtInIds: readonly string[], hasCustomBasemap: boolean): string | null {
if (selectedId === "custom" && hasCustomBasemap) return selectedId;
if (selectedId && builtInIds.includes(selectedId)) return selectedId;
return builtInIds[0] ?? null;
}
@@ -2,6 +2,7 @@ import { Map as MapIcon } from "@lucide/vue";
import { registerPreviewAction, type PreviewActionContext } from "@/lib/dataGrid/resultPreviewRegistry";
import { wktToGeoJson, type GeoJsonGeometry } from "@/lib/dataGrid/geometryPreview";
import LayerPreviewDialog from "@/components/grid/LayerPreviewDialog.vue";
import type { QueryResult } from "@/types/database";
export interface GeometryMapFeature {
type: "Feature";
@@ -21,13 +22,7 @@ registerPreviewAction({
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";
});
return hasGeometryMapPreviewColumns(result);
},
execute(ctx) {
const featureCollection = buildGeometryMapFeatureCollection(ctx);
@@ -42,6 +37,10 @@ registerPreviewAction({
},
});
export function hasGeometryMapPreviewColumns(result: Pick<QueryResult, "column_types">): boolean {
return geometryColumnIndices(result.column_types).length > 0;
}
export function buildGeometryMapFeatureCollection(ctx: PreviewActionContext): GeometryMapFeatureCollection | null {
const geomIndices = geometryColumnIndices(ctx.result.column_types);
if (geomIndices.length === 0) return null;
@@ -31,7 +31,7 @@ export interface PreviewAction {
/** 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;
isAvailable(result: QueryResult): boolean;
/**
* Execute the action. Returns a dialog configuration to render, or null
* if nothing to show (e.g. no applicable data in selected rows).
@@ -52,7 +52,7 @@ export function registerPreviewAction(action: PreviewAction): void {
/**
* Get all registered actions that are applicable for the given result.
*/
export function getApplicablePreviewActions(result: Pick<QueryResult, "columns" | "column_types">): PreviewAction[] {
export function getApplicablePreviewActions(result: QueryResult): PreviewAction[] {
const applicable: PreviewAction[] = [];
for (const action of actions.values()) {
if (action.isAvailable(result)) {