mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-05-31 00:26:49 +02:00
Added the option to view map markers with a tree view. Depending on the sort by selection, the map pins are grouped by their respective selection. Name by first character, hue, visiblity, or icon.
This commit is contained in:
parent
1af0faf279
commit
f33870cd34
|
|
@ -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<IconProps> => {
|
||||
if (!icon) return IconMapPinFilled
|
||||
|
||||
|
|
@ -77,6 +113,32 @@ const resolveMarkerIcon = (icon?: string | null): ComponentType<IconProps> => {
|
|||
return Icon ? (Icon as ComponentType<IconProps>) : 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<SortField>('name')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list')
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(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<string, MarkerGroup>()
|
||||
|
||||
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 (
|
||||
<li
|
||||
key={marker.id}
|
||||
className={`group flex items-center gap-2 border-b border-border-subtle px-3 py-2 transition-colors last:border-b-0 ${
|
||||
marker.id === selectedMarkerId ? 'bg-desert-green/10' : 'hover:bg-surface-secondary'
|
||||
} ${marker.visible ? '' : 'opacity-60'}`}
|
||||
>
|
||||
<MarkerIcon
|
||||
size={16}
|
||||
className="shrink-0"
|
||||
style={{
|
||||
color: normalizeColorHex(marker.color, marker.customColor),
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect(marker.id)
|
||||
onFlyTo(marker.longitude, marker.latitude)
|
||||
}}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
title={marker.name}
|
||||
>
|
||||
<p className="truncate text-sm font-medium text-text-primary">{marker.name}</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onToggleVisibility(marker.id, !marker.visible)
|
||||
}}
|
||||
className="shrink-0 rounded p-1 text-text-muted transition-colors hover:bg-surface-secondary hover:text-text-primary"
|
||||
title={marker.visible ? 'Hide pin' : 'Show pin'}
|
||||
aria-label={marker.visible ? 'Hide pin' : 'Show pin'}
|
||||
>
|
||||
{marker.visible ? <IconEye size={14} /> : <IconEyeOff size={14} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(marker.id)}
|
||||
className="shrink-0 rounded p-1 text-text-muted opacity-0 transition-all hover:bg-surface-secondary hover:text-desert-red group-hover:opacity-100"
|
||||
title="Delete pin"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
|
|
@ -196,10 +380,63 @@ export default function MarkerPanel({
|
|||
className="block w-full rounded border border-border-default bg-surface-primary px-2 py-1 text-sm text-text-primary placeholder:text-text-muted focus:border-desert-green focus:outline-none"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('list')}
|
||||
title="List view"
|
||||
className={`flex flex-1 items-center justify-center gap-1 rounded px-2 py-1 text-xs transition-colors ${
|
||||
viewMode === 'list'
|
||||
? 'bg-[#424420] text-white'
|
||||
: 'bg-surface-primary text-text-secondary hover:bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
<IconList size={14} />
|
||||
List
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode('tree')}
|
||||
title="Tree view"
|
||||
className={`flex flex-1 items-center justify-center gap-1 rounded px-2 py-1 text-xs transition-colors ${
|
||||
viewMode === 'tree'
|
||||
? 'bg-[#424420] text-white'
|
||||
: 'bg-surface-primary text-text-secondary hover:bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
<IconSitemap size={14} />
|
||||
Tree
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewMode === 'tree' && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={allGroupsCollapsed ? expandAllGroups : collapseAllGroups}
|
||||
className="flex-1 rounded bg-[#424420] px-2 py-1 text-xs text-white hover:bg-[#525530]"
|
||||
>
|
||||
{allGroupsCollapsed ? 'Expand all' : 'Collapse all'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAllMarkerVisibility(!allFilteredMarkersVisible)}
|
||||
className="flex-1 rounded bg-[#424420] px-2 py-1 text-xs text-white hover:bg-[#525530]"
|
||||
>
|
||||
{allFilteredMarkersVisible ? 'Hide all' : 'Show all'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={sortField}
|
||||
onChange={(e) => setSortField(e.target.value as SortField)}
|
||||
onChange={(e) => {
|
||||
setSortField(e.target.value as SortField)
|
||||
setCollapsedGroups(new Set())
|
||||
}}
|
||||
className="flex-1 rounded border border-border-default bg-surface-primary px-2 py-1 text-xs text-text-primary focus:border-desert-green focus:outline-none"
|
||||
>
|
||||
<option value="name">Sort by name</option>
|
||||
|
|
@ -218,73 +455,55 @@ export default function MarkerPanel({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[calc(100vh-240px)] overflow-y-auto">
|
||||
<div className="max-h-[calc(100vh-280px)] overflow-y-auto">
|
||||
{markers.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center">
|
||||
<IconMapPinFilled size={24} className="mx-auto mb-2 text-text-muted" />
|
||||
<p className="text-sm text-text-muted">Click anywhere on the map to drop a pin</p>
|
||||
</div>
|
||||
) : visibleMarkers.length === 0 ? (
|
||||
) : filteredAndSortedMarkers.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center">
|
||||
<p className="text-sm text-text-muted">No pins match your search.</p>
|
||||
</div>
|
||||
) : viewMode === 'list' ? (
|
||||
<ul>{filteredAndSortedMarkers.map(renderMarkerRow)}</ul>
|
||||
) : (
|
||||
<ul>
|
||||
{visibleMarkers.map((marker) => {
|
||||
const MarkerIcon = resolveMarkerIcon(marker.icon)
|
||||
<div>
|
||||
{markerGroups.map((group) => {
|
||||
const isCollapsed = collapsedGroups.has(group.key)
|
||||
const allVisible = group.markers.every((marker) => marker.visible)
|
||||
const someVisible = group.markers.some((marker) => marker.visible)
|
||||
|
||||
return (
|
||||
<li
|
||||
key={marker.id}
|
||||
className={`group flex items-center gap-2 border-b border-border-subtle px-3 py-2 transition-colors last:border-b-0 ${
|
||||
marker.id === selectedMarkerId ? 'bg-desert-green/10' : 'hover:bg-surface-secondary'
|
||||
} ${marker.visible ? '' : 'opacity-60'}`}
|
||||
>
|
||||
<MarkerIcon
|
||||
size={16}
|
||||
className="shrink-0"
|
||||
style={{
|
||||
color: normalizeColorHex(marker.color, marker.customColor),
|
||||
}}
|
||||
/>
|
||||
<div key={group.key} className="border-b border-border-subtle last:border-b-0">
|
||||
<div className="flex items-center gap-2 bg-surface-secondary/70 px-3 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroupCollapsed(group.key)}
|
||||
className="min-w-0 flex-1 text-left text-xs font-semibold text-text-primary"
|
||||
title={group.label}
|
||||
>
|
||||
<span className="truncate">
|
||||
{isCollapsed ? '▸' : '▾'} {group.label} ({group.markers.length})
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect(marker.id)
|
||||
onFlyTo(marker.longitude, marker.latitude)
|
||||
}}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
title={marker.name}
|
||||
>
|
||||
<p className="truncate text-sm font-medium text-text-primary">{marker.name}</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGroupVisibility(group, !allVisible)}
|
||||
className="shrink-0 rounded p-1 text-text-muted transition-colors hover:bg-surface-primary hover:text-text-primary"
|
||||
title={allVisible ? 'Hide all pins in group' : 'Show all pins in group'}
|
||||
aria-label={allVisible ? 'Hide all pins in group' : 'Show all pins in group'}
|
||||
>
|
||||
{allVisible || someVisible ? <IconEye size={14} /> : <IconEyeOff size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onToggleVisibility(marker.id, !marker.visible)
|
||||
}}
|
||||
className="shrink-0 rounded p-1 text-text-muted transition-colors hover:bg-surface-secondary hover:text-text-primary"
|
||||
title={marker.visible ? 'Hide pin' : 'Show pin'}
|
||||
aria-label={marker.visible ? 'Hide pin' : 'Show pin'}
|
||||
>
|
||||
{marker.visible ? <IconEye size={14}/> : <IconEyeOff size={14}/>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(marker.id)}
|
||||
className="shrink-0 rounded p-1 text-text-muted opacity-0 transition-all hover:bg-surface-secondary hover:text-desert-red group-hover:opacity-100"
|
||||
title="Delete pin"
|
||||
>
|
||||
<IconTrash size={14}/>
|
||||
</button>
|
||||
</li>
|
||||
{!isCollapsed && <ul>{group.markers.map(renderMarkerRow)}</ul>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user