Skip to content

POI image icons (proof of concept) — implementation plan

Source idea (verbatim from the request):

we want to replace some of the current icon markers with neat images (from public/images/map-icons).

Refined and scoped down (see prior clarification round) to: POI type markers only — the avesmaps dataset's read-only feature layer (AvesmapsFeatureLayer) is explicitly out of scope and deferred to a follow-up. This is a proof of concept: existing POI types get an image option, no new POI types are added, and no data/projects.json migration runs.

What's already there

  • app/lib/poi.ts — PoiTypeDefinition.icon is a plain string, currently always a lucide icon name drawn from POI_TYPE_ICONS (12 names). POI_TYPE_ICON_COMPONENTS is the lookup table from icon name to LucideIcon used both by the marker renderer and the type editor's icon picker. UNKNOWN_POI_TYPE ({ color, icon }) is the fallback used whenever an icon name doesn't resolve.
  • app/components/map.tsx's buildPoiIcon(color, iconName) (~line 346) builds an L.DivIcon: a 22px colored circle with a lucide icon rendered inside via renderToStaticMarkup. usePoiIcons (~line 374) builds one PoiIcons object ({ bySlug, unknown }) per distinct set of type definitions, and resolvePoiIcon looks a type slug up in it, falling back to unknown. PoiMarker (~line 389) is the only consumer.
  • app/routes/project.tsx's PoiTypeForm (~line 170) renders POI_TYPE_ICONS as a row of buttons, each showing its POI_TYPE_ICON_COMPONENTS[name] glyph; the selected name is submitted as the icon hidden field. The POI type list (~line 500) separately renders POI_TYPE_ICON_COMPONENTS[type.icon] inside a small colored circle badge, with no fallback to UNKNOWN_POI_TYPE if the name doesn't resolve.
  • app/lib/projects.server.ts's addPoiType/updatePoiType store whatever icon string they're given — no allow-list validation, so no server-side change is needed to accept a new kind of value.
  • data/projects.json already has multiple projects with a city POI type (e.g. lines 293, 483) using the current lucide icon. DEFAULT_POI_TYPES in poi.ts is only read at project-creation time — it is copied into a new project's poiTypes, so changing it does not touch already-saved projects.
  • public/images/map-icons has 4 PNGs today: castle.png, city.png, city_fortified.png, crossing.png. They are self-contained pictorial icons (own colors, drop shadow, transparent background) — not plain glyphs meant to sit inside a colored circle like the lucide icons do.

Decisions taken

Question Decision
Marker scope POI types only. Avesmaps dataset features (AvesmapsFeatureLayer, crossing.png) are out of scope, deferred to a follow-up task
Image-to-type mapping Map to existing POI types only, as a proof of concept — no new POI types (castle, city_fortified) are created in this task, though their images become pickable for any type
Icon value shape Single icon: string field keeps both kinds — a image:<name> value addresses one of the map-icons PNGs (e.g. image:city → /images/map-icons/city.png), a bare name keeps meaning a lucide icon name, exactly as today. No new field, no schema/type change beyond the value convention, so old data needs no migration
Extensibility Generic — the set of available images is one array (mirroring POI_TYPE_ICONS), so a future PNG dropped into public/images/map-icons becomes pickable by adding one entry, not by touching the marker or picker rendering logic
Color swatch on image icons Not applied. The images already carry their own color/shading; wrapping them in the same colored circle used for lucide icons would clash. Image icons render standalone at marker size, lucide icons keep the existing colored-circle badge
Default city type DEFAULT_POI_TYPES's city entry switches to image:city. Only affects newly created projects — no migration of data/projects.json
Fallback behavior An icon value that resolves to neither a known image nor a known lucide name falls back to UNKNOWN_POI_TYPE's rendering, same as today

Prerequisites

None — builds directly on the existing POI type system and the 4 PNGs already in public/images/map-icons.


1. Add the image icon registry and value convention to app/lib/poi.ts

Goal: poi.ts exposes the set of available image icons and a way to tell an image-icon value apart from a lucide-icon value, so both the picker and the marker renderer can share one source of truth.

Scope

  • In: A new exported list, e.g. POI_TYPE_IMAGE_ICONS: string[], holding the base names of the 4 files (castle, city, city_fortified, crossing).
  • In: A small set of exported helpers to encode/decode the image:<name> convention and resolve a base name to its public/images/map-icons path (signatures only, e.g. isPoiImageIcon(icon: string): boolean, getPoiImageIconPath(name: string): string) — implementation is straightforward string handling, not designed here.
  • In: DEFAULT_POI_TYPES's city entry changes its icon to the image:-prefixed value for city.
  • Out: No change to PoiTypeDefinition's shape (icon stays string) and no change to POI_TYPE_ICONS/POI_TYPE_ICON_COMPONENTS.

Acceptance criteria

  • poi.ts exports the image icon list and the encode/decode helpers.
  • DEFAULT_POI_TYPES's city entry uses the new image icon value.
  • pnpm typecheck passes.

2. Render image icons in app/components/map.tsx

Goal: A POI type whose icon is an image value renders that PNG as its map marker, at a comparable footprint to the existing colored-circle lucide markers, with the same click-to-select behavior.

Scope

  • In: buildPoiIcon (~line 346) or an equivalent branch reachable from usePoiIcons builds a plain image-based L.DivIcon (or L.icon) when the type's icon value is an image, instead of the colored circle + lucide glyph markup — no color swatch wrapper, aspect ratio preserved.
  • In: resolvePoiIcon/usePoiIcons's fallback path still resolves to UNKNOWN_POI_TYPE's (lucide, colored-circle) rendering when an icon value is neither a known image nor a known lucide name.
  • Out: No change to PoiMarker, MapClickHandler, or how eventHandlers are wired — only how the L.DivIcon passed to Marker is constructed.
  • Out: No change to PendingPoiMarker (icons.unknown) — the "placing a new POI" placeholder keeps its current lucide-based look regardless of the chosen type's icon, exactly as today.

Acceptance criteria

  • A POI whose type has an image icon (e.g. city after task 4) renders the corresponding PNG on the map, sized comparably to existing markers, not stretched or letterboxed.
  • Clicking an image-icon marker still calls onSelectRequest (same as a lucide-icon marker).
  • A POI whose type has an unresolved icon value still renders UNKNOWN_POI_TYPE's marker, not a broken image.
  • pnpm typecheck passes.

3. Extend the icon picker and type list preview in app/routes/project.tsx

Goal: The POI type editor lets a user pick any of the 4 map-icons images (in addition to the existing 12 lucide icons) for a new or edited type, and the type list shows the correct preview for either kind.

Scope

  • In: PoiTypeForm's icon picker (~line 231) adds a second row (or extends the existing row) of buttons for POI_TYPE_IMAGE_ICONS, each showing the PNG instead of a lucide glyph; selecting one sets the hidden icon field to the image:<name> value, same submission path as today.
  • In: The type list's icon badge (~line 500) branches on the icon value: image values render the PNG standalone (no colored circle wrapper, per the color-swatch decision above), lucide values keep the existing colored circle + glyph, and an unresolved value falls back to UNKNOWN_POI_TYPE's rendering (closing the existing gap where this preview has no fallback today).
  • Out: No change to PoiTypeForm's color swatch picker — color continues to apply only to lucide-icon types; an image-icon type simply doesn't use its stored color for the badge/marker (the field stays on the type for now, unused by image icons, rather than being conditionally hidden from the form — keeps this step small).

Acceptance criteria

  • Creating or editing a POI type shows all 4 map-icons images as selectable options alongside the 12 lucide icons.
  • Saving a type with an image icon persists the image:<name> value via the existing add-poi-type/update-poi-type actions, unchanged.
  • The type list shows the PNG for an image-icon type and the existing colored-circle glyph for a lucide-icon type.
  • pnpm typecheck and pnpm format pass.

4. Manual verification

Goal: Confirm the proof of concept end to end on a real project.

Scope

  • In: Manual check only — no code change.

Acceptance criteria

  • On a newly created project, the default city POI type shows city.png in the type list and on the map.
  • On an existing project (e.g. one of the ones in data/projects.json with a city type), the type still shows its original lucide icon, confirming no migration occurred.
  • Editing an existing type to an image icon, and back to a lucide icon, updates the marker and type list preview correctly each time.
  • pnpm build succeeds.