185 lines
5.3 KiB
TypeScript
Executable File
185 lines
5.3 KiB
TypeScript
Executable File
import { useAtom } from "jotai";
|
|
import { useDrag, useDrop, DndProvider } from "react-dnd";
|
|
import { HTML5Backend } from "react-dnd-html5-backend";
|
|
import {
|
|
ActiveLayer,
|
|
activeLayersAtom,
|
|
disableAllLayersAtom,
|
|
enableAllLayersAtom,
|
|
removeAllLayersAtom,
|
|
reorderLayersAtom,
|
|
} from "../../../state/active-layers";
|
|
import EmptyState from "../empty-state";
|
|
import { mapAtom } from "../../../state/map";
|
|
import L from "leaflet";
|
|
import { LayerControlItem } from "./layer-control-item";
|
|
import { useCallback, useRef, useState } from "react";
|
|
import { leafletLayerInstancesAtom } from "../../../state/leaflet-layer-instances";
|
|
import { Eye, EyeOff, Trash2 } from "lucide-react";
|
|
|
|
const DraggableLayerItem = ({
|
|
layer,
|
|
layerInstance,
|
|
index,
|
|
moveLayer,
|
|
onZoom,
|
|
}: {
|
|
layer: ActiveLayer;
|
|
layerInstance?: L.Layer;
|
|
index: number;
|
|
moveLayer: (dragIndex: number, hoverIndex: number) => void;
|
|
onZoom: (bounds?: L.LatLngBoundsExpression | null) => void;
|
|
}) => {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
const [{ isDragging }, drag] = useDrag({
|
|
type: "LAYER",
|
|
item: { id: layer.id, index },
|
|
collect: (monitor) => ({
|
|
isDragging: monitor.isDragging(),
|
|
}),
|
|
});
|
|
|
|
const [, drop] = useDrop({
|
|
accept: "LAYER",
|
|
drop(item: { id: string; index: number }) {
|
|
if (!ref.current) return;
|
|
|
|
const dragIndex = item.index;
|
|
const hoverIndex = index;
|
|
|
|
if (dragIndex === hoverIndex) return;
|
|
|
|
moveLayer(dragIndex, hoverIndex);
|
|
|
|
// Update the item's index so it's correct for future operations
|
|
item.index = hoverIndex;
|
|
},
|
|
});
|
|
|
|
drag(drop(ref));
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
style={{ opacity: isDragging ? 0.5 : 1 }}
|
|
className="cursor-pointer"
|
|
>
|
|
<LayerControlItem
|
|
layer={layer}
|
|
layerInstance={layerInstance}
|
|
onZoom={onZoom}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default function LayerControls() {
|
|
const [activeLayers] = useAtom(activeLayersAtom);
|
|
const [, reorderLayers] = useAtom(reorderLayersAtom);
|
|
const [, enableAllLayers] = useAtom(enableAllLayersAtom);
|
|
const [, disableAllLayers] = useAtom(disableAllLayersAtom);
|
|
const [, removeAllLayers] = useAtom(removeAllLayersAtom);
|
|
const [map] = useAtom(mapAtom);
|
|
const [layerInstances] = useAtom(leafletLayerInstancesAtom);
|
|
const [allLayersVisible, setAllLayersVisible] = useState(true);
|
|
|
|
const handleZoomToLayer = (bounds?: L.LatLngBoundsExpression | null) => {
|
|
if (!map) return;
|
|
if (bounds) {
|
|
map.fitBounds(bounds, { padding: [50, 50] });
|
|
} else {
|
|
map.setZoom(map.getZoom() + 1);
|
|
}
|
|
};
|
|
|
|
const moveLayer = useCallback(
|
|
(dragIndex: number, hoverIndex: number) => {
|
|
reorderLayers({ fromIndex: dragIndex, toIndex: hoverIndex });
|
|
},
|
|
[reorderLayers]
|
|
);
|
|
|
|
const toggleAllLayers = () => {
|
|
if (allLayersVisible) {
|
|
disableAllLayers();
|
|
activeLayers.forEach((layer) => {
|
|
const instance = layerInstances.get(layer.id);
|
|
if (instance) instance.remove();
|
|
});
|
|
} else {
|
|
enableAllLayers();
|
|
activeLayers.forEach((layer) => {
|
|
const instance = layerInstances.get(layer.id);
|
|
if (instance && map) instance.addTo(map);
|
|
});
|
|
}
|
|
setAllLayersVisible(!allLayersVisible);
|
|
};
|
|
|
|
if (activeLayers.length === 0) {
|
|
return <EmptyState />;
|
|
}
|
|
|
|
return (
|
|
<DndProvider backend={HTML5Backend}>
|
|
<div>
|
|
{/* Dataset Count */}
|
|
<div className="flex flex-col items-start mb-4 pt-4">
|
|
<div className="font-medium flex justify-center items-center">
|
|
<span>
|
|
Dataset{" "}
|
|
<span className="text-xs py-0.5 px-2 rounded bg-primary text-primary-foreground">
|
|
{activeLayers.length}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
|
|
{/* Buttons Below Dataset Text */}
|
|
<div className="flex space-x-2 mt-2 justify-between w-full text-xs">
|
|
<button
|
|
className="px-0 cursor-pointer flex items-center hover:text-primary"
|
|
onClick={toggleAllLayers}
|
|
title={allLayersVisible ? "Hide all layers" : "Show all layers"}
|
|
>
|
|
{allLayersVisible ? (
|
|
<EyeOff className="h-4 w-4 mr-1" />
|
|
) : (
|
|
<Eye className="h-4 w-4 mr-1" />
|
|
)}
|
|
{allLayersVisible ? "Sembunyikan Semua" : "Tampilkan Semua"}
|
|
</button>
|
|
<button
|
|
className="px-0 cursor-pointer flex items-center hover:text-primary"
|
|
onClick={removeAllLayers}
|
|
title={allLayersVisible ? "Hide all layers" : "Show all layers"}
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-1" />
|
|
Hapus Semua
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Layer List */}
|
|
<div className="flex flex-col space-y-4 h-[60vh] overflow-auto">
|
|
{[...activeLayers].reverse().map((layer, i) => {
|
|
const index = activeLayers.length - 1 - i;
|
|
const layerInstance = layerInstances.get(layer.id);
|
|
|
|
return (
|
|
<DraggableLayerItem
|
|
key={layer.id}
|
|
index={index}
|
|
layer={layer}
|
|
layerInstance={layerInstance}
|
|
moveLayer={moveLayer}
|
|
onZoom={handleZoomToLayer}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</DndProvider>
|
|
);
|
|
}
|