mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 05:14:19 +02:00
refactor(maps): curate the marker icon set, drop the tree view
Review changes from looking at the feature running on NOMAD3. The icon picker offered every Tabler icon *and* every Font Awesome icon via namespace imports: 160 pages, whose first page included the Amazon and Amazon Pay logos, an "Ad" badge and text-alignment glyphs. It also took the maps chunk from 864 kB to 5,774 kB. Replaced with a hand-written set of 36 map-relevant icons in a 6x6 grid -- water, shelter, medical, power, comms, transport, terrain, hazards -- with no search box or paging, because 36 fit on screen. Maps chunk is back to 904 kB, +40 kB over dev rather than +4,910 kB, and the react-icons dependency is gone entirely since Tabler was already present. Icon resolution moved into marker_icons.ts so the panel and the pin share one definition, with a caller-supplied fallback (the pin's inner glyph defaults to a filled circle, the list to a map pin). An icon later removed from the set still renders as the fallback rather than blanking the marker. Dropped the tree view and the List/Tree band: grouping pins by hue or by icon organises them by an attribute the user picked arbitrarily, and grouping by first letter is worse than sorting. Search and sort cover the real need. "Hide all" lived inside the tree view, so it moved up into the header where it applies to whatever the list shows -- it is the one bulk control worth keeping. The title bar now closes the panel, matching the "Pins" button that opens it; clicking a title to open but having to hit a small X to close is an asymmetry that makes the panel feel fiddly. The list also reclaims the height the removed band was using. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
This commit is contained in:
parent
72e0fcf4dd
commit
9c1e945815
|
|
@ -1,11 +1,4 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import * as TablerIcons from '@tabler/icons-react'
|
||||
import type { IconProps } from '@tabler/icons-react'
|
||||
import type { ComponentType } from 'react'
|
||||
import * as FontAwesomeIcons from 'react-icons/fa'
|
||||
import type { IconType } from 'react-icons'
|
||||
|
||||
const PAGE_SIZE = 48
|
||||
import { MARKER_ICONS } from './marker_icons'
|
||||
|
||||
type IconSelectorPopoverProps = {
|
||||
selectedIcon?: string | null
|
||||
|
|
@ -13,144 +6,43 @@ type IconSelectorPopoverProps = {
|
|||
onClose: () => void
|
||||
}
|
||||
|
||||
const normalizeIconNameForSort = (name: string) => {
|
||||
return name
|
||||
.replace(/^Icon/, '') // Tabler: IconHome -> Home
|
||||
.replace(/^Fa/, '') // FontAwesome: FaHome -> Home
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
type IconEntry = {
|
||||
name: string
|
||||
label: string
|
||||
library: 'tabler' | 'fa'
|
||||
Icon: ComponentType<IconProps> | IconType
|
||||
}
|
||||
|
||||
const tablerIconEntries: IconEntry[] = Object.entries(TablerIcons)
|
||||
.filter(([name, value]) => {
|
||||
return (
|
||||
name.startsWith('Icon') &&
|
||||
name !== 'Icon' &&
|
||||
value !== null &&
|
||||
(typeof value === 'function' || typeof value === 'object')
|
||||
)
|
||||
})
|
||||
.map(([name, Icon]) => ({
|
||||
name: `tabler:${name}`,
|
||||
label: name,
|
||||
library: 'tabler' as const,
|
||||
Icon: Icon as ComponentType<IconProps>,
|
||||
}))
|
||||
|
||||
const fontAwesomeIconEntries: IconEntry[] = Object.entries(FontAwesomeIcons)
|
||||
.filter(([name, value]) => {
|
||||
return (
|
||||
name.startsWith('Fa') &&
|
||||
value !== null &&
|
||||
(typeof value === 'function' || typeof value === 'object')
|
||||
)
|
||||
})
|
||||
.map(([name, Icon]) => ({
|
||||
name: `fa:${name}`,
|
||||
label: name,
|
||||
library: 'fa' as const,
|
||||
Icon: Icon as IconType,
|
||||
}))
|
||||
|
||||
const iconEntries: IconEntry[] = [
|
||||
...tablerIconEntries,
|
||||
...fontAwesomeIconEntries,
|
||||
].sort((a, b) => a.label.localeCompare(b.label))
|
||||
|
||||
/**
|
||||
* The marker icon picker: the whole curated set, six across and six down.
|
||||
*
|
||||
* No search box and no paging, because there is nothing to search or page
|
||||
* through -- 36 icons fit on screen at once, which is the point of curating
|
||||
* them. See marker_icons.ts for why the set is a hand-written list.
|
||||
*/
|
||||
export default function IconSelectorPopover({
|
||||
selectedIcon,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: IconSelectorPopoverProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const filteredIcons = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
|
||||
return iconEntries.filter((entry) => {
|
||||
const normalizedLabel = normalizeIconNameForSort(entry.label)
|
||||
|
||||
return (
|
||||
entry.label.toLowerCase().includes(normalizedQuery) ||
|
||||
normalizedLabel.includes(normalizedQuery) ||
|
||||
entry.library.toLowerCase().includes(normalizedQuery)
|
||||
)
|
||||
})
|
||||
}, [query])
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filteredIcons.length / PAGE_SIZE))
|
||||
const pagedIcons = filteredIcons.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE)
|
||||
|
||||
selectedIcon,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: IconSelectorPopoverProps) {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-7 z-50 w-72 rounded-md border border-border-subtle bg-surface-primary p-2 shadow-lg"
|
||||
className="absolute left-0 top-7 z-50 rounded-md border border-border-subtle bg-surface-primary p-2 shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
type="search"
|
||||
placeholder="Search icons..."
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
className="mb-2 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="max-h-56 overflow-y-auto themed-scrollbar">
|
||||
<div className="grid grid-cols-6 gap-1">
|
||||
{pagedIcons.map(({ name, label, library, Icon }) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
title={`${label} (${library})`}
|
||||
aria-label={label}
|
||||
onClick={() => {
|
||||
onSelect(name)
|
||||
onClose()
|
||||
}}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded transition-colors hover:bg-surface-secondary ${
|
||||
selectedIcon === name ? 'bg-desert-green text-white' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((prev) => Math.max(0, prev - 1))}
|
||||
disabled={page === 0}
|
||||
className="rounded bg-[#424420] px-2 py-1 text-xs text-white hover:bg-[#525530] disabled:opacity-40"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-text-muted">
|
||||
{filteredIcons.length === 0 ? 'No icons' : `${page + 1} / ${pageCount}`}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((prev) => Math.min(pageCount - 1, prev + 1))}
|
||||
disabled={page >= pageCount - 1}
|
||||
className="rounded bg-[#424420] px-2 py-1 text-xs text-white hover:bg-[#525530] disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<div className="grid grid-cols-6 gap-1">
|
||||
{MARKER_ICONS.map(({ name, label, Icon }) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={() => {
|
||||
onSelect(name)
|
||||
onClose()
|
||||
}}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded transition-colors hover:bg-surface-secondary ${
|
||||
selectedIcon === name ? 'bg-desert-green text-white' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { resolveMarkerIcon } from './marker_icons'
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
IconEye,
|
||||
|
|
@ -9,9 +10,6 @@ import {
|
|||
IconTrash,
|
||||
IconX,
|
||||
} from '@tabler/icons-react'
|
||||
import * as FontAwesomeIcons from 'react-icons/fa'
|
||||
import type { IconType } from 'react-icons'
|
||||
import * as TablerIcons from '@tabler/icons-react'
|
||||
import type { IconProps } from '@tabler/icons-react'
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
|
|
@ -29,7 +27,6 @@ interface MarkerPanelProps {
|
|||
|
||||
type SortField = 'name' | 'color' | 'visibility' | 'icon'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
type ViewMode = 'list' | 'tree'
|
||||
|
||||
type ColorSortValue = {
|
||||
bucket: number
|
||||
|
|
@ -115,58 +112,6 @@ const getReadableIconName = (icon?: string | null) => {
|
|||
.replace(/^Fa/, '')
|
||||
.replace(/^Icon/, '')
|
||||
}
|
||||
const resolveMarkerIcon = (
|
||||
icon?: string | null
|
||||
): ComponentType<IconProps> | IconType => {
|
||||
if (!icon) return IconMapPinFilled
|
||||
|
||||
// Font Awesome
|
||||
if (icon.startsWith('fa:')) {
|
||||
const iconName = icon.replace('fa:', '')
|
||||
const Icon = (FontAwesomeIcons as Record<string, unknown>)[iconName]
|
||||
return Icon ? (Icon as IconType) : IconMapPinFilled
|
||||
}
|
||||
|
||||
// Tabler
|
||||
if (icon.startsWith('tabler:')) {
|
||||
const iconName = icon.replace('tabler:', '')
|
||||
const Icon = (TablerIcons as Record<string, unknown>)[iconName]
|
||||
return Icon ? (Icon as ComponentType<IconProps>) : IconMapPinFilled
|
||||
}
|
||||
|
||||
// Backward compatibility
|
||||
const Icon =
|
||||
(TablerIcons as Record<string, unknown>)[icon] ??
|
||||
(FontAwesomeIcons as Record<string, unknown>)[icon]
|
||||
|
||||
return Icon ? (Icon as ComponentType<IconProps> | IconType) : 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 = getReadableIconName(marker.icon)
|
||||
return { key: label, label }
|
||||
}
|
||||
|
||||
return marker.visible
|
||||
? { key: 'visible', label: 'Visible' }
|
||||
: { key: 'hidden', label: 'Hidden' }
|
||||
}
|
||||
|
||||
export default function MarkerPanel({
|
||||
markers,
|
||||
|
|
@ -180,8 +125,6 @@ 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'
|
||||
|
|
@ -232,66 +175,10 @@ 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) {
|
||||
|
|
@ -377,7 +264,23 @@ export default function MarkerPanel({
|
|||
|
||||
return (
|
||||
<div className="absolute left-4 top-[72px] z-40 w-72 rounded-lg border border-border-subtle bg-surface-primary/95 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between border-b border-border-subtle px-3 py-2.5">
|
||||
{/* The whole title bar closes the panel, mirroring the "Pins" button that
|
||||
opens it -- clicking the title to open but having to find the X to
|
||||
close is the kind of asymmetry that makes a panel feel fiddly. The X
|
||||
stays as the visible affordance. */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setOpen(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
title="Close panel"
|
||||
className="flex cursor-pointer items-center justify-between border-b border-border-subtle px-3 py-2.5 transition-colors hover:bg-surface-secondary"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconMapPin size={18} className="text-desert-orange" />
|
||||
|
||||
|
|
@ -390,14 +293,12 @@ export default function MarkerPanel({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded p-0.5 text-text-muted transition-colors hover:bg-surface-secondary hover:text-text-primary"
|
||||
title="Close panel"
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="rounded p-0.5 text-text-muted transition-colors"
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-b border-border-subtle px-3 py-2">
|
||||
|
|
@ -409,56 +310,6 @@ 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}
|
||||
|
|
@ -482,9 +333,22 @@ export default function MarkerPanel({
|
|||
{sortDirectionLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hide all lived inside the tree view before it was removed; it is the
|
||||
one bulk control worth keeping, so it moved up here where it applies
|
||||
to whatever the list currently shows. */}
|
||||
{filteredAndSortedMarkers.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAllMarkerVisibility(!allFilteredMarkersVisible)}
|
||||
className="w-full rounded bg-[#424420] px-2 py-1 text-xs text-white transition-colors hover:bg-[#525530]"
|
||||
>
|
||||
{allFilteredMarkersVisible ? 'Hide all' : 'Show all'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[calc(100vh-280px)] overflow-y-auto">
|
||||
<div className="max-h-[calc(100vh-244px)] 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" />
|
||||
|
|
@ -494,45 +358,8 @@ export default function MarkerPanel({
|
|||
<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>
|
||||
) : (
|
||||
<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 (
|
||||
<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={() => 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>
|
||||
|
||||
{!isCollapsed && <ul>{group.markers.map(renderMarkerRow)}</ul>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<ul>{filteredAndSortedMarkers.map(renderMarkerRow)}</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { resolveMarkerIcon } from './marker_icons'
|
||||
import { IconCircleFilled } from '@tabler/icons-react'
|
||||
import * as TablerIcons from '@tabler/icons-react'
|
||||
import type { IconProps } from '@tabler/icons-react'
|
||||
import type { IconType } from 'react-icons'
|
||||
import * as FontAwesomeIcons from 'react-icons/fa'
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
import { PIN_COLORS } from '~/hooks/useMapMarkers'
|
||||
|
|
@ -44,31 +42,6 @@ const getContrastingIconColor = (backgroundColor: string) => {
|
|||
return luminance > 0.55 ? '#111827' : '#ffffff'
|
||||
}
|
||||
|
||||
type MarkerIconComponent =
|
||||
| ComponentType<IconProps>
|
||||
| IconType
|
||||
|
||||
const resolveIcon = (icon?: string | null): MarkerIconComponent => {
|
||||
if (!icon) return IconCircleFilled
|
||||
|
||||
if (icon.startsWith('fa:')) {
|
||||
const iconName = icon.replace('fa:', '')
|
||||
const Icon = (FontAwesomeIcons as Record<string, unknown>)[iconName]
|
||||
return Icon ? (Icon as IconType) : IconCircleFilled
|
||||
}
|
||||
|
||||
if (icon.startsWith('tabler:')) {
|
||||
const iconName = icon.replace('tabler:', '')
|
||||
const Icon = (TablerIcons as Record<string, unknown>)[iconName]
|
||||
return Icon ? (Icon as ComponentType<IconProps>) : IconCircleFilled
|
||||
}
|
||||
|
||||
const Icon =
|
||||
(TablerIcons as Record<string, unknown>)[icon] ??
|
||||
(FontAwesomeIcons as Record<string, unknown>)[icon]
|
||||
|
||||
return Icon ? (Icon as MarkerIconComponent) : IconCircleFilled
|
||||
}
|
||||
|
||||
export default function MarkerPin({
|
||||
color = 'orange',
|
||||
|
|
@ -82,7 +55,8 @@ export default function MarkerPin({
|
|||
|
||||
const resolvedColor = resolvePinColor(color, customColor)
|
||||
const resolvedIconColor = iconColor ?? getContrastingIconColor(resolvedColor)
|
||||
const Icon = resolveIcon(icon)
|
||||
// A pin's inner glyph defaults to a filled circle, not a map pin.
|
||||
const Icon = resolveMarkerIcon(icon, IconCircleFilled)
|
||||
|
||||
const width = active ? 42 : 36
|
||||
const height = active ? 52 : 46
|
||||
|
|
|
|||
137
admin/inertia/components/maps/marker_icons.ts
Normal file
137
admin/inertia/components/maps/marker_icons.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import {
|
||||
IconAlertTriangle,
|
||||
IconAmbulance,
|
||||
IconAntenna,
|
||||
IconBandage,
|
||||
IconBarrel,
|
||||
IconBattery,
|
||||
IconBed,
|
||||
IconBolt,
|
||||
IconBuildingHospital,
|
||||
IconCampfire,
|
||||
IconCar,
|
||||
IconDoor,
|
||||
IconDroplet,
|
||||
IconFirstAidKit,
|
||||
IconFish,
|
||||
IconFlag,
|
||||
IconGasStation,
|
||||
IconHome,
|
||||
IconMapPinFilled,
|
||||
IconMeat,
|
||||
IconMountain,
|
||||
IconPackage,
|
||||
IconPill,
|
||||
IconRadio,
|
||||
IconRoute,
|
||||
IconSeeding,
|
||||
IconSkull,
|
||||
IconSolarPanel,
|
||||
IconStethoscope,
|
||||
IconTent,
|
||||
IconTool,
|
||||
IconToolsKitchen2,
|
||||
IconTractor,
|
||||
IconTrees,
|
||||
IconTruck,
|
||||
IconWheat,
|
||||
IconWifi,
|
||||
} from '@tabler/icons-react'
|
||||
import type { IconProps } from '@tabler/icons-react'
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
/**
|
||||
* The marker icon set: a curated 36, laid out as six rows of six.
|
||||
*
|
||||
* Deliberately named imports rather than `import * as TablerIcons`. The
|
||||
* namespace form pulls the entire icon library into the maps bundle -- when this
|
||||
* picker offered all of Tabler *and* all of Font Awesome it took the maps chunk
|
||||
* from 864 kB to 5,774 kB, and gave the user 160 pages to scroll through,
|
||||
* including brand logos and text-alignment glyphs. Neither is what someone
|
||||
* marking a water source needs.
|
||||
*
|
||||
* Adding an icon means adding it here, which is the point: the list stays
|
||||
* meaningful for marking a place on a map you are relying on offline.
|
||||
*/
|
||||
export type MarkerIconEntry = {
|
||||
/** Stored on the marker row, e.g. `tabler:IconDroplet`. */
|
||||
name: string
|
||||
/** Shown as the button tooltip. */
|
||||
label: string
|
||||
Icon: ComponentType<IconProps>
|
||||
}
|
||||
|
||||
const entry = (
|
||||
Icon: ComponentType<IconProps>,
|
||||
tablerName: string,
|
||||
label: string
|
||||
): MarkerIconEntry => ({ name: `tabler:${tablerName}`, label, Icon })
|
||||
|
||||
export const MARKER_ICONS: MarkerIconEntry[] = [
|
||||
// Water and food
|
||||
entry(IconDroplet, 'IconDroplet', 'Water'),
|
||||
entry(IconBarrel, 'IconBarrel', 'Water storage'),
|
||||
entry(IconToolsKitchen2, 'IconToolsKitchen2', 'Food'),
|
||||
entry(IconWheat, 'IconWheat', 'Grain or crops'),
|
||||
entry(IconMeat, 'IconMeat', 'Meat or game'),
|
||||
entry(IconFish, 'IconFish', 'Fishing'),
|
||||
|
||||
// Shelter and living
|
||||
entry(IconHome, 'IconHome', 'Building'),
|
||||
entry(IconTent, 'IconTent', 'Camp'),
|
||||
entry(IconBed, 'IconBed', 'Shelter'),
|
||||
entry(IconDoor, 'IconDoor', 'Entrance'),
|
||||
entry(IconCampfire, 'IconCampfire', 'Fire'),
|
||||
entry(IconSeeding, 'IconSeeding', 'Garden'),
|
||||
|
||||
// Medical
|
||||
entry(IconFirstAidKit, 'IconFirstAidKit', 'First aid'),
|
||||
entry(IconBuildingHospital, 'IconBuildingHospital', 'Hospital'),
|
||||
entry(IconStethoscope, 'IconStethoscope', 'Clinic'),
|
||||
entry(IconPill, 'IconPill', 'Medication'),
|
||||
entry(IconBandage, 'IconBandage', 'Supplies'),
|
||||
entry(IconAmbulance, 'IconAmbulance', 'Ambulance'),
|
||||
|
||||
// Power and communications
|
||||
entry(IconBolt, 'IconBolt', 'Power'),
|
||||
entry(IconSolarPanel, 'IconSolarPanel', 'Solar'),
|
||||
entry(IconBattery, 'IconBattery', 'Battery'),
|
||||
entry(IconAntenna, 'IconAntenna', 'Antenna'),
|
||||
entry(IconRadio, 'IconRadio', 'Radio'),
|
||||
entry(IconWifi, 'IconWifi', 'Network'),
|
||||
|
||||
// Transport and supply
|
||||
entry(IconGasStation, 'IconGasStation', 'Fuel'),
|
||||
entry(IconCar, 'IconCar', 'Vehicle'),
|
||||
entry(IconTruck, 'IconTruck', 'Truck'),
|
||||
entry(IconTractor, 'IconTractor', 'Machinery'),
|
||||
entry(IconPackage, 'IconPackage', 'Cache or supplies'),
|
||||
entry(IconTool, 'IconTool', 'Tools'),
|
||||
|
||||
// Terrain, routes and hazards
|
||||
entry(IconMountain, 'IconMountain', 'High ground'),
|
||||
entry(IconTrees, 'IconTrees', 'Woodland'),
|
||||
entry(IconRoute, 'IconRoute', 'Route'),
|
||||
entry(IconFlag, 'IconFlag', 'Rally point'),
|
||||
entry(IconAlertTriangle, 'IconAlertTriangle', 'Hazard'),
|
||||
entry(IconSkull, 'IconSkull', 'Danger'),
|
||||
]
|
||||
|
||||
/** The pin used when a marker has no icon, or names one no longer in the set. */
|
||||
export const DEFAULT_MARKER_ICON = IconMapPinFilled
|
||||
|
||||
const BY_NAME = new Map(MARKER_ICONS.map((i) => [i.name, i.Icon]))
|
||||
|
||||
/**
|
||||
* Resolve a stored icon name to a component, falling back to the default pin.
|
||||
*
|
||||
* The fallback matters beyond bad data: a marker saved with an icon that is
|
||||
* later removed from the set must still render, rather than blanking the pin.
|
||||
*/
|
||||
export function resolveMarkerIcon(
|
||||
icon?: string | null,
|
||||
fallback: ComponentType<IconProps> = DEFAULT_MARKER_ICON
|
||||
): ComponentType<IconProps> {
|
||||
if (!icon) return fallback
|
||||
return BY_NAME.get(icon) ?? fallback
|
||||
}
|
||||
10
admin/package-lock.json
generated
10
admin/package-lock.json
generated
|
|
@ -71,7 +71,6 @@
|
|||
"react": "19.2.7",
|
||||
"react-adonis-transmit": "1.0.1",
|
||||
"react-dom": "19.2.7",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-map-gl": "8.1.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"reflect-metadata": "0.2.2",
|
||||
|
|
@ -16450,15 +16449,6 @@
|
|||
"react": "^19.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz",
|
||||
"integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
|
|
|
|||
|
|
@ -129,7 +129,6 @@
|
|||
"react": "19.2.7",
|
||||
"react-adonis-transmit": "1.0.1",
|
||||
"react-dom": "19.2.7",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-map-gl": "8.1.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"reflect-metadata": "0.2.2",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user