diff --git a/admin/inertia/components/maps/MarkerPanel.tsx b/admin/inertia/components/maps/MarkerPanel.tsx index 025b37c..f7a58b8 100644 --- a/admin/inertia/components/maps/MarkerPanel.tsx +++ b/admin/inertia/components/maps/MarkerPanel.tsx @@ -2,14 +2,17 @@ import { useMemo, useState } from 'react' import { IconEye, IconEyeOff, + IconList, IconMapPin, IconMapPinFilled, + IconSitemap, IconTrash, IconX, } from '@tabler/icons-react' import * as TablerIcons from '@tabler/icons-react' import type { IconProps } from '@tabler/icons-react' import type { ComponentType } from 'react' + import { PIN_COLORS } from '~/hooks/useMapMarkers' import type { MapMarker } from '~/hooks/useMapMarkers' @@ -24,6 +27,19 @@ interface MarkerPanelProps { type SortField = 'name' | 'color' | 'visibility' | 'icon' type SortDirection = 'asc' | 'desc' +type ViewMode = 'list' | 'tree' + +type ColorSortValue = { + bucket: number + hue: number + lightness: number +} + +type MarkerGroup = { + key: string + label: string + markers: MapMarker[] +} const normalizeColorHex = (color: string, customColor?: string | null) => { if (customColor) return customColor @@ -32,7 +48,7 @@ const normalizeColorHex = (color: string, customColor?: string | null) => { return preset?.hex ?? color } -const getColorSortValue = (color: string, customColor?: string | null) => { +const getColorSortValue = (color: string, customColor?: string | null): ColorSortValue => { const hex = normalizeColorHex(color, customColor).replace('#', '') if (!/^[0-9a-fA-F]{6}$/.test(hex)) { @@ -69,6 +85,26 @@ const getColorSortValue = (color: string, customColor?: string | null) => { } } +const getHueGroupLabel = ({ bucket, hue, lightness }: ColorSortValue) => { + if (bucket === 0) { + return lightness < 0.34 + ? 'Grayscale — Dark' + : lightness < 0.67 + ? 'Grayscale — Mid' + : 'Grayscale — Light' + } + + if (bucket === 2) return 'Other colors' + + if (hue < 30 || hue >= 330) return 'Red' + if (hue < 60) return 'Orange' + if (hue < 90) return 'Yellow' + if (hue < 150) return 'Green' + if (hue < 210) return 'Cyan' + if (hue < 270) return 'Blue' + return 'Purple' +} + const resolveMarkerIcon = (icon?: string | null): ComponentType => { if (!icon) return IconMapPinFilled @@ -77,6 +113,32 @@ const resolveMarkerIcon = (icon?: string | null): ComponentType => { return Icon ? (Icon as ComponentType) : IconMapPinFilled } +const getMarkerGroup = (marker: MapMarker, sortField: SortField) => { + if (sortField === 'name') { + const firstLetter = marker.name.trim().charAt(0).toUpperCase() + + if (!firstLetter || !/[A-Z]/.test(firstLetter)) { + return { key: '#', label: '#' } + } + + return { key: firstLetter, label: firstLetter } + } + + if (sortField === 'color') { + const label = getHueGroupLabel(getColorSortValue(marker.color, marker.customColor)) + return { key: label, label } + } + + if (sortField === 'icon') { + const label = marker.icon || 'Default pin' + return { key: label, label } + } + + return marker.visible + ? { key: 'visible', label: 'Visible' } + : { key: 'hidden', label: 'Hidden' } +} + export default function MarkerPanel({ markers, onDelete, @@ -89,6 +151,8 @@ export default function MarkerPanel({ const [searchQuery, setSearchQuery] = useState('') const [sortField, setSortField] = useState('name') const [sortDirection, setSortDirection] = useState('asc') + const [viewMode, setViewMode] = useState('list') + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) const sortDirectionLabel = sortField === 'name' @@ -107,7 +171,7 @@ export default function MarkerPanel({ ? 'Hidden first' : 'Visible first' - const visibleMarkers = useMemo(() => { + const filteredAndSortedMarkers = useMemo(() => { const query = searchQuery.trim().toLowerCase() return [...markers] @@ -119,14 +183,11 @@ export default function MarkerPanel({ const result = sortField === 'name' ? a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) - : sortField === 'visibility' ? Number(a.visible) - Number(b.visible) - : sortField === 'icon' ? (a.icon ? 1 : 0) - (b.icon ? 1 : 0) || - (a.icon ?? '').localeCompare(b.icon ?? '') - + (a.icon ?? '').localeCompare(b.icon ?? '', undefined, { sensitivity: 'base' }) : (() => { const aColor = getColorSortValue(a.color, a.customColor) const bColor = getColorSortValue(b.color, b.customColor) @@ -142,6 +203,129 @@ export default function MarkerPanel({ }) }, [markers, searchQuery, sortField, sortDirection]) + const markerGroups = useMemo(() => { + const groups = new Map() + + filteredAndSortedMarkers.forEach((marker) => { + const group = getMarkerGroup(marker, sortField) + + if (!groups.has(group.key)) { + groups.set(group.key, { + key: group.key, + label: group.label, + markers: [], + }) + } + + groups.get(group.key)?.markers.push(marker) + }) + + return Array.from(groups.values()).sort((a, b) => { + const result = a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }) + return sortDirection === 'asc' ? result : -result + }) + }, [filteredAndSortedMarkers, sortField, sortDirection]) + + const allGroupsCollapsed = + markerGroups.length > 0 && markerGroups.every((group) => collapsedGroups.has(group.key)) + + const allFilteredMarkersVisible = + filteredAndSortedMarkers.length > 0 && + filteredAndSortedMarkers.every((marker) => marker.visible) + + const toggleGroupCollapsed = (groupKey: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev) + + if (next.has(groupKey)) { + next.delete(groupKey) + } else { + next.add(groupKey) + } + + return next + }) + } + + const collapseAllGroups = () => { + setCollapsedGroups(new Set(markerGroups.map((group) => group.key))) + } + + const expandAllGroups = () => { + setCollapsedGroups(new Set()) + } + + const setGroupVisibility = (group: MarkerGroup, visible: boolean) => { + group.markers.forEach((marker) => { + if (marker.visible !== visible) { + onToggleVisibility(marker.id, visible) + } + }) + } + + const setAllMarkerVisibility = (visible: boolean) => { + filteredAndSortedMarkers.forEach((marker) => { + if (marker.visible !== visible) { + onToggleVisibility(marker.id, visible) + } + }) + } + + const renderMarkerRow = (marker: MapMarker) => { + const MarkerIcon = resolveMarkerIcon(marker.icon) + + return ( +
  • + + + + + + + +
  • + ) + } + if (!open) { return ( + + + + + {viewMode === 'tree' && ( +
    + + + +
    + )} +