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:
@@ -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) {
|
||||
mapHandleRef.current?.clearSelection();
|
||||
setSelectedFeatures([]);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
const handleSelectionModeChange = useCallback((mode: SelectionMode) => {
|
||||
if (mode === "off") {
|
||||
mapHandleRef.current?.clearSelection();
|
||||
setSelectedFeatures([]);
|
||||
}
|
||||
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 */}
|
||||
<Button
|
||||
variant={selectionMode ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs gap-1"
|
||||
onClick={onToggleSelectionMode}
|
||||
title={selectionMode ? "Dezactiveaza selectia" : "Activeaza selectia"}
|
||||
>
|
||||
<MousePointerClick className="h-3.5 w-3.5" />
|
||||
Selectie
|
||||
</Button>
|
||||
<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
|
||||
key={btn.mode}
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs gap-1"
|
||||
onClick={() => onSelectionModeChange(isActive ? "off" : btn.mode)}
|
||||
title={btn.tooltip}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user