feat(geoportal): selection modes (click/rectangle/freehand) + export DXF/GPKG only

- Selection toolbar: 3 modes — Click (individual), Dreptunghi (area), Desen (freehand)
- Each mode has tooltip explaining usage
- Export: removed GeoJSON, only DXF + GPKG. GPKG labeled "cu metadata"
- DXF export fix: -s_srs + -t_srs (was -a_srs + -t_srs)

Note: rectangle and freehand drawing on map not yet implemented (UI ready,
map interaction coming next session).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-24 07:57:34 +02:00
parent 78625d6415
commit 7d2fe4ade0
2 changed files with 67 additions and 74 deletions
@@ -5,7 +5,7 @@ import dynamic from "next/dynamic";
import { LayerPanel, getDefaultVisibility } from "./layer-panel";
import { BasemapSwitcher } from "./basemap-switcher";
import { SearchBar } from "./search-bar";
import { SelectionToolbar } from "./selection-toolbar";
import { SelectionToolbar, type SelectionMode } from "./selection-toolbar";
import { FeatureInfoPanel } from "./feature-info-panel";
import type { MapViewerHandle } from "./map-viewer";
import type {
@@ -29,7 +29,7 @@ export function GeoportalModule() {
const [basemap, setBasemap] = useState<BasemapId>("liberty");
const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>(getDefaultVisibility);
const [clickedFeature, setClickedFeature] = useState<ClickedFeature | null>(null);
const [selectionMode, setSelectionMode] = useState(false);
const [selectionMode, setSelectionMode] = useState<SelectionMode>("off");
const [selectedFeatures, setSelectedFeatures] = useState<SelectedFeature[]>([]);
const [flyTarget, setFlyTarget] = useState<{ center: [number, number]; zoom?: number } | undefined>();
@@ -48,14 +48,12 @@ export function GeoportalModule() {
}
}, []);
const handleToggleSelectionMode = useCallback(() => {
setSelectionMode((prev) => {
if (prev) {
const handleSelectionModeChange = useCallback((mode: SelectionMode) => {
if (mode === "off") {
mapHandleRef.current?.clearSelection();
setSelectedFeatures([]);
}
return !prev;
});
setSelectionMode(mode);
}, []);
return (
@@ -64,7 +62,7 @@ export function GeoportalModule() {
ref={mapHandleRef}
className="h-full w-full"
basemap={basemap}
selectionMode={selectionMode}
selectionMode={selectionMode !== "off"}
onFeatureClick={handleFeatureClick}
onSelectionChange={setSelectedFeatures}
layerVisibility={layerVisibility}
@@ -81,7 +79,7 @@ export function GeoportalModule() {
{/* Top-right: basemap switcher + feature panel (aligned) */}
<div className="absolute top-3 right-14 z-10 flex flex-col items-end gap-2">
<BasemapSwitcher value={basemap} onChange={setBasemap} />
{clickedFeature && !selectionMode && (
{clickedFeature && selectionMode === "off" && (
<FeatureInfoPanel feature={clickedFeature} onClose={() => setClickedFeature(null)} />
)}
</div>
@@ -91,7 +89,7 @@ export function GeoportalModule() {
<SelectionToolbar
selectedFeatures={selectedFeatures}
selectionMode={selectionMode}
onToggleSelectionMode={handleToggleSelectionMode}
onSelectionModeChange={handleSelectionModeChange}
onClearSelection={() => { mapHandleRef.current?.clearSelection(); setSelectedFeatures([]); }}
/>
</div>
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Download, Trash2, MousePointerClick, Loader2 } from "lucide-react";
import { Download, Trash2, MousePointerClick, Square, PenTool, Loader2 } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import {
DropdownMenu,
@@ -13,33 +13,40 @@ import { Badge } from "@/shared/components/ui/badge";
import { cn } from "@/shared/lib/utils";
import type { SelectedFeature, ExportFormat } from "../types";
export type SelectionMode = "off" | "click" | "rect" | "freehand";
type SelectionToolbarProps = {
selectedFeatures: SelectedFeature[];
selectionMode: boolean;
onToggleSelectionMode: () => void;
selectionMode: SelectionMode;
onSelectionModeChange: (mode: SelectionMode) => void;
onClearSelection: () => void;
className?: string;
};
const EXPORT_FORMATS: { id: ExportFormat; label: string; ext: string }[] = [
{ id: "geojson", label: "GeoJSON (.geojson)", ext: "geojson" },
{ id: "dxf", label: "AutoCAD DXF (.dxf)", ext: "dxf" },
{ id: "gpkg", label: "GeoPackage (.gpkg)", ext: "gpkg" },
const EXPORT_FORMATS: { id: ExportFormat; label: string }[] = [
{ id: "dxf", label: "AutoCAD DXF (.dxf)" },
{ id: "gpkg", label: "GeoPackage (.gpkg) — cu metadata" },
];
const MODE_BUTTONS: { mode: SelectionMode; icon: typeof MousePointerClick; label: string; tooltip: string }[] = [
{ mode: "click", icon: MousePointerClick, label: "Click", tooltip: "Click pe parcele individuale pentru a le selecta/deselecta" },
{ mode: "rect", icon: Square, label: "Dreptunghi", tooltip: "Trage un dreptunghi pe harta pentru a selecta toate parcelele din zona" },
{ mode: "freehand", icon: PenTool, label: "Desen", tooltip: "Deseneaza o zona libera pe harta (click puncte, dublu-click pentru a inchide)" },
];
export function SelectionToolbar({
selectedFeatures,
selectionMode,
onToggleSelectionMode,
onSelectionModeChange,
onClearSelection,
className,
}: SelectionToolbarProps) {
const [exporting, setExporting] = useState(false);
const active = selectionMode !== "off";
const handleExport = async (format: ExportFormat) => {
if (selectedFeatures.length === 0) return;
setExporting(true);
try {
const ids = selectedFeatures.map((f) => f.id);
const resp = await fetch("/api/geoportal/export", {
@@ -47,17 +54,13 @@ export function SelectionToolbar({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids, format }),
});
if (!resp.ok) {
const errData = await resp.json().catch(() => null);
const msg = (errData && typeof errData === "object" && "error" in errData)
? String((errData as { error: string }).error)
: `Export esuat (${resp.status})`;
? String((errData as { error: string }).error) : `Export esuat (${resp.status})`;
alert(msg);
return;
}
// Download the blob
const blob = await resp.blob();
const disposition = resp.headers.get("Content-Disposition");
let filename = `export.${format}`;
@@ -65,7 +68,6 @@ export function SelectionToolbar({
const match = disposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)/i);
if (match?.[1]) filename = decodeURIComponent(match[1]);
}
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@@ -80,71 +82,64 @@ export function SelectionToolbar({
};
return (
<div
className={cn(
"bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg flex items-center gap-1.5 p-1.5",
className
)}
>
{/* Toggle selection mode */}
<div className={cn("bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg p-1.5 flex flex-col gap-1.5", className)}>
{/* Selection mode row */}
<div className="flex items-center gap-1">
{MODE_BUTTONS.map((btn) => {
const Icon = btn.icon;
const isActive = selectionMode === btn.mode;
return (
<Button
variant={selectionMode ? "default" : "ghost"}
key={btn.mode}
variant={isActive ? "default" : "ghost"}
size="sm"
className="h-7 px-2 text-xs gap-1"
onClick={onToggleSelectionMode}
title={selectionMode ? "Dezactiveaza selectia" : "Activeaza selectia"}
onClick={() => onSelectionModeChange(isActive ? "off" : btn.mode)}
title={btn.tooltip}
>
<MousePointerClick className="h-3.5 w-3.5" />
Selectie
<Icon className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{btn.label}</span>
</Button>
);
})}
</div>
{/* Help text when selection active */}
{active && selectedFeatures.length === 0 && (
<p className="text-xs text-muted-foreground px-1">
{selectionMode === "click" && "Click pe parcele pentru a le selecta"}
{selectionMode === "rect" && "Trage un dreptunghi pe harta"}
{selectionMode === "freehand" && "Click puncte pe harta, dublu-click pentru a inchide zona"}
</p>
)}
{/* Count + export + clear */}
{selectedFeatures.length > 0 && (
<>
<div className="flex items-center gap-1.5">
<Badge variant="secondary" className="text-xs h-5 px-1.5">
{selectedFeatures.length}
{selectedFeatures.length} parcele
</Badge>
{/* Export dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1"
disabled={exporting}
>
{exporting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" disabled={exporting}>
{exporting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />}
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="start">
{EXPORT_FORMATS.map((fmt) => (
<DropdownMenuItem
key={fmt.id}
onClick={() => handleExport(fmt.id)}
className="text-xs"
>
<DropdownMenuItem key={fmt.id} onClick={() => handleExport(fmt.id)} className="text-xs">
{fmt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Clear selection */}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={onClearSelection}
title="Sterge selectia"
>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={onClearSelection} title="Sterge selectia">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
</div>
)}
</div>
);