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:
Chris Sherwood 2026-08-30 15:01:39 -07:00 committed by Jake Turner
parent 72e0fcf4dd
commit 9c1e945815
6 changed files with 208 additions and 389 deletions

View File

@ -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">

View File

@ -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>

View File

@ -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

View 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
}

View File

@ -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",

View File

@ -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",