Added font awesome icons to the map markers

This commit is contained in:
Kenneth Brewer 2026-05-06 03:53:52 -04:00
parent 3b068eceb0
commit 38c4dc3fd4
7 changed files with 274 additions and 140 deletions

View File

@ -2,6 +2,8 @@ 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
@ -11,7 +13,21 @@ type IconSelectorPopoverProps = {
onClose: () => void
}
const iconEntries = Object.entries(TablerIcons)
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') &&
@ -20,7 +36,32 @@ const iconEntries = Object.entries(TablerIcons)
(typeof value === 'function' || typeof value === 'object')
)
})
.sort(([a], [b]) => a.localeCompare(b)) as Array<[string, ComponentType<IconProps>]>
.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))
export default function IconSelectorPopover({
selectedIcon,
@ -33,11 +74,18 @@ export default function IconSelectorPopover({
const filteredIcons = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase()
return iconEntries.filter(([name]) => name.toLowerCase().includes(normalizedQuery))
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)
return (
@ -61,12 +109,12 @@ export default function IconSelectorPopover({
<div className="max-h-56 overflow-y-auto themed-scrollbar">
<div className="grid grid-cols-6 gap-1">
{pagedIcons.map(([name, Icon]) => (
{pagedIcons.map(({ name, label, library, Icon }) => (
<button
key={name}
type="button"
title={name}
aria-label={name}
title={`${label} (${library})`}
aria-label={label}
onClick={() => {
onSelect(name)
onClose()

View File

@ -21,7 +21,7 @@ import ViewMapMarkerPopup from './ViewMapMarkerPopup'
import MapMarkerFormPopup from './MapMarkerFormPopup'
import ScaleUnitToggle from './ScaleUnitSelector'
type ScaleUnit = 'imperial' | 'metric'
type ScaleUnit = 'imperial' | 'metric' | 'nautical'
type MapCommand = {
id: number
@ -108,9 +108,15 @@ export default function MapComponent({
const [hasUnsavedMarkerChanges, setHasUnsavedMarkerChanges] = useState(false)
const [showCoordinates, setShowCoordinates] = useState(false)
const [scaleUnit, setScaleUnit] = useState<ScaleUnit>(
() => (localStorage.getItem('nomad:map-scale-unit') as ScaleUnit) || 'metric'
)
const getInitialScaleUnit = (): ScaleUnit => {
const stored = localStorage.getItem('nomad:map-scale-unit')
return stored === 'metric' || stored === 'imperial' || stored === 'nautical'
? stored
: 'metric'
}
const [scaleUnit, setScaleUnit] = useState<ScaleUnit>(getInitialScaleUnit)
const [cursorLngLat, setCursorLngLat] = useState<{
lng: number

View File

@ -9,6 +9,8 @@ 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'
@ -104,13 +106,40 @@ const getHueGroupLabel = ({ bucket, hue, lightness }: ColorSortValue) => {
if (hue < 270) return 'Blue'
return 'Purple'
}
const getReadableIconName = (icon?: string | null) => {
if (!icon) return 'Default pin'
const resolveMarkerIcon = (icon?: string | null): ComponentType<IconProps> => {
return icon
.replace(/^fa:/, '')
.replace(/^tabler:/, '')
.replace(/^Fa/, '')
.replace(/^Icon/, '')
}
const resolveMarkerIcon = (
icon?: string | null
): ComponentType<IconProps> | IconType => {
if (!icon) return IconMapPinFilled
const Icon = (TablerIcons as Record<string, unknown>)[icon]
// 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
}
return Icon ? (Icon as ComponentType<IconProps>) : 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) => {
@ -130,7 +159,7 @@ const getMarkerGroup = (marker: MapMarker, sortField: SortField) => {
}
if (sortField === 'icon') {
const label = marker.icon || 'Default pin'
const label = getReadableIconName(marker.icon)
return { key: label, label }
}

View File

@ -1,6 +1,8 @@
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'
@ -42,14 +44,30 @@ const getContrastingIconColor = (backgroundColor: string) => {
return luminance > 0.55 ? '#111827' : '#ffffff'
}
const resolveIcon = (icon?: string | null): ComponentType<IconProps> => {
type MarkerIconComponent =
| ComponentType<IconProps>
| IconType
const resolveIcon = (icon?: string | null): MarkerIconComponent => {
if (!icon) return IconCircleFilled
const Icon = (TablerIcons as Record<string, unknown>)[icon]
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) return 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
}
return Icon as ComponentType<IconProps>
const Icon =
(TablerIcons as Record<string, unknown>)[icon] ??
(FontAwesomeIcons as Record<string, unknown>)[icon]
return Icon ? (Icon as MarkerIconComponent) : IconCircleFilled
}
export default function MarkerPin({

View File

@ -1,4 +1,4 @@
type ScaleUnit = 'imperial' | 'metric'
type ScaleUnit = 'imperial' | 'metric' | 'nautical'
type ScaleUnitSelectorProps = {
scaleUnit: ScaleUnit
@ -7,41 +7,63 @@ type ScaleUnitSelectorProps = {
}
export default function ScaleUnitSelector({
scaleUnit,
onChange,
onMouseEnter,
}: ScaleUnitSelectorProps) {
const getButtonStyle = (unit: ScaleUnit) => ({
background: scaleUnit === unit ? '#424420' : 'white',
color: scaleUnit === unit ? 'white' : '#666',
cursor: 'pointer',
})
scaleUnit,
onChange,
onMouseEnter,
}: ScaleUnitSelectorProps) {
return (
<div className="absolute bottom-[30px] left-[10px] z-[2]" onMouseEnter={onMouseEnter}>
<div className="inline-flex overflow-hidden rounded text-[11px] font-semibold leading-none shadow-[0_0_0_2px_rgba(0,0,0,0.1)]">
return (
<div className="absolute bottom-[30px] left-[10px] z-[2]" onMouseEnter={onMouseEnter}>
<div className="inline-flex overflow-hidden rounded text-[11px] font-semibold leading-none shadow-[0_0_0_2px_rgba(0,0,0,0.1)]">
<button
type="button"
onClick={() => {
if (scaleUnit !== 'metric') onChange('metric')
}}
className="border-0 px-2 py-1"
style={getButtonStyle('metric')}
>
Metric
</button>
{/* Metric */}
<button
type="button"
onClick={() => {
if (scaleUnit !== 'metric') onChange('metric')
}}
className="border-0 px-2 py-1"
style={{
background: scaleUnit === 'metric' ? '#424420' : 'white',
color: scaleUnit === 'metric' ? 'white' : '#666',
cursor: 'pointer',
}}
>
Metric
</button>
<button
type="button"
onClick={() => {
if (scaleUnit !== 'imperial') onChange('imperial')
}}
className="border-0 px-2 py-1"
style={getButtonStyle('imperial')}
>
Imperial
</button>
</div>
</div>
)
{/* Imperial */}
<button
type="button"
onClick={() => {
if (scaleUnit !== 'imperial') onChange('imperial')
}}
className="border-0 px-2 py-1"
style={{
background: scaleUnit === 'imperial' ? '#424420' : 'white',
color: scaleUnit === 'imperial' ? 'white' : '#666',
cursor: 'pointer',
}}
>
Imperial
</button>
{/* Nautical */}
<button
type="button"
onClick={() => {
if (scaleUnit !== 'nautical') onChange('nautical')
}}
className="border-0 px-2 py-1"
style={{
background: scaleUnit === 'nautical' ? '#424420' : 'white',
color: scaleUnit === 'nautical' ? 'white' : '#666',
cursor: 'pointer',
}}
>
Nautical
</button>
</div>
</div>
)
}

180
admin/package-lock.json generated
View File

@ -9,94 +9,95 @@
"version": "0.0.0",
"license": "ISC",
"dependencies": {
"@adonisjs/auth": "^9.4.0",
"@adonisjs/core": "^6.18.0",
"@adonisjs/cors": "^2.2.1",
"@adonisjs/inertia": "^3.1.1",
"@adonisjs/lucid": "^21.8.2",
"@adonisjs/session": "^7.5.1",
"@adonisjs/shield": "^8.2.0",
"@adonisjs/static": "^1.1.1",
"@adonisjs/transmit": "^2.0.2",
"@adonisjs/transmit-client": "^1.0.0",
"@adonisjs/vite": "^4.0.0",
"@chonkiejs/core": "^0.0.7",
"@headlessui/react": "^2.2.4",
"@inertiajs/react": "^2.0.13",
"@markdoc/markdoc": "^0.5.2",
"@openzim/libzim": "^4.0.0",
"@protomaps/basemaps": "^5.7.0",
"@qdrant/js-client-rest": "^1.16.2",
"@tabler/icons-react": "^3.34.0",
"@tailwindcss/vite": "^4.1.10",
"@tanstack/react-query": "^5.81.5",
"@tanstack/react-query-devtools": "^5.83.0",
"@tanstack/react-virtual": "^3.13.12",
"@uppy/core": "^5.2.0",
"@uppy/dashboard": "^5.1.0",
"@uppy/react": "^5.1.1",
"@vinejs/vine": "^3.0.1",
"@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10.4.21",
"axios": "^1.15.0",
"better-sqlite3": "^12.1.1",
"bullmq": "^5.65.1",
"cheerio": "^1.2.0",
"compression": "^1.8.1",
"dockerode": "^4.0.7",
"edge.js": "^6.2.1",
"fast-xml-parser": "^5.5.7",
"fuse.js": "^7.1.0",
"jszip": "^3.10.1",
"luxon": "^3.6.1",
"maplibre-gl": "^4.7.1",
"mysql2": "^3.14.1",
"ollama": "^0.6.3",
"openai": "^6.27.0",
"pdf-parse": "^2.4.5",
"pdf2pic": "^3.2.0",
"pino-pretty": "^13.0.0",
"pmtiles": "^4.4.0",
"postcss": "^8.5.6",
"react": "^19.1.0",
"react-adonis-transmit": "^1.0.1",
"react-dom": "^19.1.0",
"react-map-gl": "^8.1.0",
"react-markdown": "^10.1.0",
"reflect-metadata": "^0.2.2",
"remark-gfm": "^4.0.1",
"sharp": "^0.34.5",
"stopword": "^3.1.5",
"systeminformation": "^5.31.0",
"tailwindcss": "^4.2.1",
"tar": "^7.5.11",
"tesseract.js": "^7.0.0",
"url-join": "^5.0.0",
"yaml": "^2.8.3"
"@adonisjs/auth": "9.6.0",
"@adonisjs/core": "6.19.3",
"@adonisjs/cors": "2.2.1",
"@adonisjs/inertia": "3.1.1",
"@adonisjs/lucid": "21.8.2",
"@adonisjs/session": "7.7.1",
"@adonisjs/shield": "8.2.0",
"@adonisjs/static": "1.1.1",
"@adonisjs/transmit": "2.0.2",
"@adonisjs/transmit-client": "1.1.0",
"@adonisjs/vite": "4.0.0",
"@chonkiejs/core": "0.0.7",
"@headlessui/react": "2.2.9",
"@inertiajs/react": "2.3.13",
"@markdoc/markdoc": "0.5.4",
"@openzim/libzim": "4.0.0",
"@protomaps/basemaps": "5.7.0",
"@qdrant/js-client-rest": "1.16.2",
"@tabler/icons-react": "3.36.1",
"@tailwindcss/vite": "4.1.18",
"@tanstack/react-query": "5.90.20",
"@tanstack/react-query-devtools": "5.91.3",
"@tanstack/react-virtual": "3.13.18",
"@uppy/core": "5.2.0",
"@uppy/dashboard": "5.1.0",
"@uppy/react": "5.1.1",
"@vinejs/vine": "3.0.1",
"@vitejs/plugin-react": "4.7.0",
"autoprefixer": "10.4.24",
"axios": "1.15.0",
"better-sqlite3": "12.6.2",
"bullmq": "5.67.2",
"cheerio": "1.2.0",
"compression": "1.8.1",
"dockerode": "4.0.9",
"edge.js": "6.4.0",
"fast-xml-parser": "5.5.9",
"fuse.js": "7.1.0",
"jszip": "3.10.1",
"luxon": "3.7.2",
"maplibre-gl": "4.7.1",
"mysql2": "3.16.2",
"ollama": "0.6.3",
"openai": "6.27.0",
"pdf-parse": "2.4.5",
"pdf2pic": "3.2.0",
"pino-pretty": "13.1.3",
"pmtiles": "4.4.0",
"postcss": "8.5.6",
"react": "19.2.4",
"react-adonis-transmit": "1.0.1",
"react-dom": "19.2.4",
"react-icons": "5.6.0",
"react-map-gl": "8.1.0",
"react-markdown": "10.1.0",
"reflect-metadata": "0.2.2",
"remark-gfm": "4.0.1",
"sharp": "0.34.5",
"stopword": "3.1.5",
"systeminformation": "5.31.0",
"tailwindcss": "4.2.2",
"tar": "7.5.11",
"tesseract.js": "7.0.0",
"url-join": "5.0.0",
"yaml": "2.8.3"
},
"devDependencies": {
"@adonisjs/assembler": "^7.8.2",
"@adonisjs/eslint-config": "^2.0.0",
"@adonisjs/prettier-config": "^1.4.4",
"@adonisjs/tsconfig": "^1.4.0",
"@japa/assert": "^4.0.1",
"@japa/plugin-adonisjs": "^4.0.0",
"@japa/runner": "^4.2.0",
"@adonisjs/assembler": "7.8.2",
"@adonisjs/eslint-config": "2.1.2",
"@adonisjs/prettier-config": "1.4.5",
"@adonisjs/tsconfig": "1.4.1",
"@japa/assert": "4.2.0",
"@japa/plugin-adonisjs": "4.0.0",
"@japa/runner": "4.5.0",
"@swc/core": "1.11.24",
"@tanstack/eslint-plugin-query": "^5.81.2",
"@types/compression": "^1.8.1",
"@types/dockerode": "^4.0.1",
"@types/luxon": "^3.6.2",
"@types/node": "^22.15.18",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/stopword": "^2.0.3",
"eslint": "^9.26.0",
"hot-hook": "^0.4.0",
"prettier": "^3.5.3",
"ts-node-maintained": "^10.9.5",
"typescript": "~5.8.3",
"vite": "^6.4.2"
"@tanstack/eslint-plugin-query": "5.91.4",
"@types/compression": "1.8.1",
"@types/dockerode": "4.0.1",
"@types/luxon": "3.7.1",
"@types/node": "22.19.7",
"@types/react": "19.2.10",
"@types/react-dom": "19.2.3",
"@types/stopword": "2.0.3",
"eslint": "9.39.2",
"hot-hook": "0.4.0",
"prettier": "3.8.1",
"ts-node-maintained": "10.9.6",
"typescript": "5.8.3",
"vite": "6.4.2"
}
},
"node_modules/@adobe/css-tools": {
@ -14052,6 +14053,15 @@
"react": "^19.2.4"
}
},
"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

@ -114,6 +114,7 @@
"react": "19.2.4",
"react-adonis-transmit": "1.0.1",
"react-dom": "19.2.4",
"react-icons": "^5.6.0",
"react-map-gl": "8.1.0",
"react-markdown": "10.1.0",
"reflect-metadata": "0.2.2",