From 9c1e945815d075a1ae6f693accb1f8949720b24e Mon Sep 17 00:00:00 2001 From: Chris Sherwood Date: Sun, 30 Aug 2026 15:01:39 -0700 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV --- .../components/maps/IconSelectorPopover.tsx | 170 +++--------- admin/inertia/components/maps/MarkerPanel.tsx | 247 +++--------------- admin/inertia/components/maps/MarkerPin.tsx | 32 +-- admin/inertia/components/maps/marker_icons.ts | 137 ++++++++++ admin/package-lock.json | 10 - admin/package.json | 1 - 6 files changed, 208 insertions(+), 389 deletions(-) create mode 100644 admin/inertia/components/maps/marker_icons.ts diff --git a/admin/inertia/components/maps/IconSelectorPopover.tsx b/admin/inertia/components/maps/IconSelectorPopover.tsx index f0310910..ef7a6afa 100644 --- a/admin/inertia/components/maps/IconSelectorPopover.tsx +++ b/admin/inertia/components/maps/IconSelectorPopover.tsx @@ -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 | 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, - })) - -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 (
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} > - { - 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" - /> - -
-
- {pagedIcons.map(({ name, label, library, Icon }) => ( - - ))} -
-
- -
- - - - {filteredIcons.length === 0 ? 'No icons' : `${page + 1} / ${pageCount}`} - - - +
+ {MARKER_ICONS.map(({ name, label, Icon }) => ( + + ))}
diff --git a/admin/inertia/components/maps/MarkerPanel.tsx b/admin/inertia/components/maps/MarkerPanel.tsx index edfa021b..0a0856f7 100644 --- a/admin/inertia/components/maps/MarkerPanel.tsx +++ b/admin/inertia/components/maps/MarkerPanel.tsx @@ -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 | IconType => { - if (!icon) return IconMapPinFilled - - // Font Awesome - if (icon.startsWith('fa:')) { - const iconName = icon.replace('fa:', '') - const Icon = (FontAwesomeIcons as Record)[iconName] - return Icon ? (Icon as IconType) : IconMapPinFilled - } - - // Tabler - if (icon.startsWith('tabler:')) { - const iconName = icon.replace('tabler:', '') - const Icon = (TablerIcons as Record)[iconName] - return Icon ? (Icon as ComponentType) : IconMapPinFilled - } - - // Backward compatibility - const Icon = - (TablerIcons as Record)[icon] ?? - (FontAwesomeIcons as Record)[icon] - - return Icon ? (Icon as ComponentType | 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('name') const [sortDirection, setSortDirection] = useState('asc') - const [viewMode, setViewMode] = useState('list') - const [collapsedGroups, setCollapsedGroups] = useState>(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() - - 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 (
-
+ {/* 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. */} +
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" + >
@@ -390,14 +293,12 @@ export default function MarkerPanel({ )}
- +
@@ -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" /> -
- - - -
- - {viewMode === 'tree' && ( -
- - - -
- )} -