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 { LayerPanel, getDefaultVisibility } from "./layer-panel";
|
||||||
import { BasemapSwitcher } from "./basemap-switcher";
|
import { BasemapSwitcher } from "./basemap-switcher";
|
||||||
import { SearchBar } from "./search-bar";
|
import { SearchBar } from "./search-bar";
|
||||||
import { SelectionToolbar } from "./selection-toolbar";
|
import { SelectionToolbar, type SelectionMode } from "./selection-toolbar";
|
||||||
import { FeatureInfoPanel } from "./feature-info-panel";
|
import { FeatureInfoPanel } from "./feature-info-panel";
|
||||||
import type { MapViewerHandle } from "./map-viewer";
|
import type { MapViewerHandle } from "./map-viewer";
|
||||||
import type {
|
import type {
|
||||||
@@ -29,7 +29,7 @@ export function GeoportalModule() {
|
|||||||
const [basemap, setBasemap] = useState<BasemapId>("liberty");
|
const [basemap, setBasemap] = useState<BasemapId>("liberty");
|
||||||
const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>(getDefaultVisibility);
|
const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>(getDefaultVisibility);
|
||||||
const [clickedFeature, setClickedFeature] = useState<ClickedFeature | null>(null);
|
const [clickedFeature, setClickedFeature] = useState<ClickedFeature | null>(null);
|
||||||
const [selectionMode, setSelectionMode] = useState(false);
|
const [selectionMode, setSelectionMode] = useState<SelectionMode>("off");
|
||||||
const [selectedFeatures, setSelectedFeatures] = useState<SelectedFeature[]>([]);
|
const [selectedFeatures, setSelectedFeatures] = useState<SelectedFeature[]>([]);
|
||||||
const [flyTarget, setFlyTarget] = useState<{ center: [number, number]; zoom?: number } | undefined>();
|
const [flyTarget, setFlyTarget] = useState<{ center: [number, number]; zoom?: number } | undefined>();
|
||||||
|
|
||||||
@@ -48,14 +48,12 @@ export function GeoportalModule() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleToggleSelectionMode = useCallback(() => {
|
const handleSelectionModeChange = useCallback((mode: SelectionMode) => {
|
||||||
setSelectionMode((prev) => {
|
if (mode === "off") {
|
||||||
if (prev) {
|
|
||||||
mapHandleRef.current?.clearSelection();
|
mapHandleRef.current?.clearSelection();
|
||||||
setSelectedFeatures([]);
|
setSelectedFeatures([]);
|
||||||
}
|
}
|
||||||
return !prev;
|
setSelectionMode(mode);
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -64,7 +62,7 @@ export function GeoportalModule() {
|
|||||||
ref={mapHandleRef}
|
ref={mapHandleRef}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
basemap={basemap}
|
basemap={basemap}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode !== "off"}
|
||||||
onFeatureClick={handleFeatureClick}
|
onFeatureClick={handleFeatureClick}
|
||||||
onSelectionChange={setSelectedFeatures}
|
onSelectionChange={setSelectedFeatures}
|
||||||
layerVisibility={layerVisibility}
|
layerVisibility={layerVisibility}
|
||||||
@@ -81,7 +79,7 @@ export function GeoportalModule() {
|
|||||||
{/* Top-right: basemap switcher + feature panel (aligned) */}
|
{/* Top-right: basemap switcher + feature panel (aligned) */}
|
||||||
<div className="absolute top-3 right-14 z-10 flex flex-col items-end gap-2">
|
<div className="absolute top-3 right-14 z-10 flex flex-col items-end gap-2">
|
||||||
<BasemapSwitcher value={basemap} onChange={setBasemap} />
|
<BasemapSwitcher value={basemap} onChange={setBasemap} />
|
||||||
{clickedFeature && !selectionMode && (
|
{clickedFeature && selectionMode === "off" && (
|
||||||
<FeatureInfoPanel feature={clickedFeature} onClose={() => setClickedFeature(null)} />
|
<FeatureInfoPanel feature={clickedFeature} onClose={() => setClickedFeature(null)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -91,7 +89,7 @@ export function GeoportalModule() {
|
|||||||
<SelectionToolbar
|
<SelectionToolbar
|
||||||
selectedFeatures={selectedFeatures}
|
selectedFeatures={selectedFeatures}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
onToggleSelectionMode={handleToggleSelectionMode}
|
onSelectionModeChange={handleSelectionModeChange}
|
||||||
onClearSelection={() => { mapHandleRef.current?.clearSelection(); setSelectedFeatures([]); }}
|
onClearSelection={() => { mapHandleRef.current?.clearSelection(); setSelectedFeatures([]); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { Button } from "@/shared/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -13,33 +13,40 @@ import { Badge } from "@/shared/components/ui/badge";
|
|||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
import type { SelectedFeature, ExportFormat } from "../types";
|
import type { SelectedFeature, ExportFormat } from "../types";
|
||||||
|
|
||||||
|
export type SelectionMode = "off" | "click" | "rect" | "freehand";
|
||||||
|
|
||||||
type SelectionToolbarProps = {
|
type SelectionToolbarProps = {
|
||||||
selectedFeatures: SelectedFeature[];
|
selectedFeatures: SelectedFeature[];
|
||||||
selectionMode: boolean;
|
selectionMode: SelectionMode;
|
||||||
onToggleSelectionMode: () => void;
|
onSelectionModeChange: (mode: SelectionMode) => void;
|
||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EXPORT_FORMATS: { id: ExportFormat; label: string; ext: string }[] = [
|
const EXPORT_FORMATS: { id: ExportFormat; label: string }[] = [
|
||||||
{ id: "geojson", label: "GeoJSON (.geojson)", ext: "geojson" },
|
{ id: "dxf", label: "AutoCAD DXF (.dxf)" },
|
||||||
{ id: "dxf", label: "AutoCAD DXF (.dxf)", ext: "dxf" },
|
{ id: "gpkg", label: "GeoPackage (.gpkg) — cu metadata" },
|
||||||
{ id: "gpkg", label: "GeoPackage (.gpkg)", ext: "gpkg" },
|
];
|
||||||
|
|
||||||
|
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({
|
export function SelectionToolbar({
|
||||||
selectedFeatures,
|
selectedFeatures,
|
||||||
selectionMode,
|
selectionMode,
|
||||||
onToggleSelectionMode,
|
onSelectionModeChange,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
className,
|
className,
|
||||||
}: SelectionToolbarProps) {
|
}: SelectionToolbarProps) {
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const active = selectionMode !== "off";
|
||||||
|
|
||||||
const handleExport = async (format: ExportFormat) => {
|
const handleExport = async (format: ExportFormat) => {
|
||||||
if (selectedFeatures.length === 0) return;
|
if (selectedFeatures.length === 0) return;
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ids = selectedFeatures.map((f) => f.id);
|
const ids = selectedFeatures.map((f) => f.id);
|
||||||
const resp = await fetch("/api/geoportal/export", {
|
const resp = await fetch("/api/geoportal/export", {
|
||||||
@@ -47,17 +54,13 @@ export function SelectionToolbar({
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ids, format }),
|
body: JSON.stringify({ ids, format }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const errData = await resp.json().catch(() => null);
|
const errData = await resp.json().catch(() => null);
|
||||||
const msg = (errData && typeof errData === "object" && "error" in errData)
|
const msg = (errData && typeof errData === "object" && "error" in errData)
|
||||||
? String((errData as { error: string }).error)
|
? String((errData as { error: string }).error) : `Export esuat (${resp.status})`;
|
||||||
: `Export esuat (${resp.status})`;
|
|
||||||
alert(msg);
|
alert(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download the blob
|
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
const disposition = resp.headers.get("Content-Disposition");
|
const disposition = resp.headers.get("Content-Disposition");
|
||||||
let filename = `export.${format}`;
|
let filename = `export.${format}`;
|
||||||
@@ -65,7 +68,6 @@ export function SelectionToolbar({
|
|||||||
const match = disposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)/i);
|
const match = disposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)/i);
|
||||||
if (match?.[1]) filename = decodeURIComponent(match[1]);
|
if (match?.[1]) filename = decodeURIComponent(match[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -80,71 +82,64 @@ export function SelectionToolbar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn("bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg p-1.5 flex flex-col gap-1.5", className)}>
|
||||||
className={cn(
|
{/* Selection mode row */}
|
||||||
"bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg flex items-center gap-1.5 p-1.5",
|
<div className="flex items-center gap-1">
|
||||||
className
|
{MODE_BUTTONS.map((btn) => {
|
||||||
)}
|
const Icon = btn.icon;
|
||||||
>
|
const isActive = selectionMode === btn.mode;
|
||||||
{/* Toggle selection mode */}
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant={selectionMode ? "default" : "ghost"}
|
key={btn.mode}
|
||||||
|
variant={isActive ? "default" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 px-2 text-xs gap-1"
|
className="h-7 px-2 text-xs gap-1"
|
||||||
onClick={onToggleSelectionMode}
|
onClick={() => onSelectionModeChange(isActive ? "off" : btn.mode)}
|
||||||
title={selectionMode ? "Dezactiveaza selectia" : "Activeaza selectia"}
|
title={btn.tooltip}
|
||||||
>
|
>
|
||||||
<MousePointerClick className="h-3.5 w-3.5" />
|
<Icon className="h-3.5 w-3.5" />
|
||||||
Selectie
|
<span className="hidden sm:inline">{btn.label}</span>
|
||||||
</Button>
|
</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 && (
|
{selectedFeatures.length > 0 && (
|
||||||
<>
|
<div className="flex items-center gap-1.5">
|
||||||
<Badge variant="secondary" className="text-xs h-5 px-1.5">
|
<Badge variant="secondary" className="text-xs h-5 px-1.5">
|
||||||
{selectedFeatures.length}
|
{selectedFeatures.length} parcele
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
{/* Export dropdown */}
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" disabled={exporting}>
|
||||||
variant="ghost"
|
{exporting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />}
|
||||||
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
|
Export
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="start">
|
||||||
{EXPORT_FORMATS.map((fmt) => (
|
{EXPORT_FORMATS.map((fmt) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem key={fmt.id} onClick={() => handleExport(fmt.id)} className="text-xs">
|
||||||
key={fmt.id}
|
|
||||||
onClick={() => handleExport(fmt.id)}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{fmt.label}
|
{fmt.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</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" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user