Skip to content

Improved rendering of custom features — implementation plan

Source idea (verbatim from the request):

  • add a proper custom features control panel (like for images and avesmaps) that allows to filter the shown features by type. also group them by their "group" field, as done for the avesmaps features.
  • add min- and max-zoomlevel fields to the custom features config in the project, so that we can show/hide them at certain zoomlevels.
  • show labels on the map (e.g. city names) - but ensure to not clutter the map with too many labels, and only show them at certain zoomlevels. (e.g. show town names only when zoomed in enough, but not when zoomed out to the world map - which would only show metropoles/großstädte)

"Custom features" = points of interest, lines, and areas (the three project-defined feature kinds in app/lib/poi.ts, app/lib/line.ts and app/lib/area.ts) — as opposed to the read-only Avesmaps dataset layer, which already has its own filter panel and per-subtype minimum zoom (AvesmapsLayerControl, getAvesmapsSubtypeMinZoom).

What's already there

  • PoiTypeDefinition (app/lib/poi.ts) already has an optional group field. LineTypeDefinition (app/lib/line.ts) and AreaTypeDefinition (app/lib/area.ts) do not have group yet — only slug, name, color.
  • None of the three type definitions have minZoom/maxZoom. ImageOverlayLayer already has a minZoom (no maxZoom) and map.tsx's ImageOverlayLayerView (~line 292) already shows the exact pattern to reuse: track zoom via useMap() + useMapEvent("zoomend", …), then shouldShow = visible && zoom >= layer.minZoom.
  • Two existing per-map filter panels are the pattern to follow: avesmaps-layer-control.tsx (groups by feature_type, tracks disabled layer keys, persists to localStorage) and image-overlay-control.tsx (same "track what's off" approach, flat list). Both are wired into project-map.tsx (~lines 648-650, 1093-1101) and passed into <Map> as isXEnabled predicates.
  • PoiLayer, LineLayer, AreaLayer in map.tsx currently render every POI / line / area unconditionally — no visibility or zoom filtering exists for them today.
  • The project settings type editors in project.tsx (POI form ~line 273, POI list ~line 786; line form/list ~line 888; area form/list ~line 980) are where group/zoom inputs need to be added.
  • app/lib/projects.server.ts has matching CRUD pairs for all three: add/updatePoiType, add/updateLineType, add/updateAreaType, plus add/updatePointOfInterest, add/updateLine, add/updateArea for instances — all currently take a fixed, explicit field list per function (no passthrough), so every new field needs a matching parameter added by hand.
  • There is no existing label-rendering or label-collision code anywhere in the app. docs/features/versions/v0.0.3/show-labels-on-map.md is an empty stub for the third bullet — step 7 below supersedes it.

Decisions taken

Question Decision
Scope Points of interest, lines, and areas are all in scope (not POIs only)
Zoom field granularity Lives on the type definition (poiTypes/lineTypes/areaTypes in the project); an individual POI/line/area may optionally override it
Override semantics Field-by-field: an instance's minZoom (if set) replaces the type's minZoom; same independently for maxZoom. Unset instance fields fall back to the type's value
No range set anywhere Always visible (matches today's unconditional rendering — backward compatible with existing data/projects.json)
Filter panel state Per-map, localStorage only, "track what's off" — never written to data/projects.json, same as the two existing controls
Filter panel grouping Grouped by each type's own group field, scoped within its own kind (POI types grouped among POI types, etc.) — mirrors group being defined separately per type collection today
Combining filter + zoom AND: a feature renders only if its type/group is enabled in the panel and the current zoom is within its effective range
Labels Reuse the same effective zoom range as visibility (a "metropole" type gets a low minZoom so its label survives zoomed out; a "dorf" type gets a high minZoom) — no separate label-zoom field
Label anchor POI: its own position. Line/area: midpoint of the point list (first/last average for a line, centroid average for an area) — simplest anchor, not a true polygon centroid
Label collision handling Priority + pixel-radius suppression (see step 7) — not a general layout algorithm, not a new dependency

Prerequisites

None — builds directly on the existing POI/line/area type system, the existing zoom-tracking pattern in ImageOverlayLayerView, and the two existing filter-panel components as templates.


1. Add group/minZoom/maxZoom to the type & instance data models

Goal: The project's POI, line, and area type definitions can carry a group name and a zoom range, and an individual instance can optionally override that range, with one shared helper to resolve "is this visible at zoom Z" consistently everywhere it's needed.

Scope

  • In: group?: string added to LineTypeDefinition and AreaTypeDefinition in line.ts / area.ts (POI already has it).
  • In: minZoom?: number and maxZoom?: number added to PoiTypeDefinition, LineTypeDefinition, AreaTypeDefinition.
  • In: minZoom?: number and maxZoom?: number added to PointOfInterest, LineFeature, AreaFeature as optional per-instance overrides.
  • In: A new shared module, e.g. app/lib/feature-zoom.ts, exporting a ZoomRange = { minZoom?: number; maxZoom?: number } type and two pure functions: one to resolve an instance's effective range against its type's range (field-by-field fallback, per the decision above), one to test whether a given zoom is within a resolved range (undefined bound = no limit on that side).
  • Out: No server or UI wiring yet — this step only changes types/shared logic.

Acceptance criteria

  • LineTypeDefinition/AreaTypeDefinition have group; all three type definitions have minZoom/maxZoom.
  • PointOfInterest/LineFeature/AreaFeature have optional minZoom/maxZoom overrides.
  • app/lib/feature-zoom.ts exports the range-resolving and range-testing functions, with no existing behavior depending on them yet.
  • pnpm typecheck passes.

Affected areas


2. Thread the new fields through projects.server.ts

Goal: Creating or editing a POI/line/area type, or an individual POI/line/area, can set group (types only) and minZoom/maxZoom, and existing saved projects load unchanged (fields simply absent/undefined).

Scope

  • In: addPoiType/updatePoiType, addLineType/updateLineType, addAreaType/updateAreaType accept and store minZoom/maxZoom; the line/area pair also accepts and stores group (POI's already does).
  • In: addPointOfInterest/updatePointOfInterest, addLine/updateLine, addArea/updateArea accept and store optional minZoom/maxZoom overrides.
  • Out: No change to readProjectsFile's migration block — optional fields need no default-backfill (undefined is already the correct "no range" state).

Acceptance criteria

  • Every listed CRUD function's parameter type and body includes the new field(s); omitting them behaves exactly as before.
  • Loading an existing data/projects.json project (with no group/ zoom fields anywhere) still works, with those fields simply absent.
  • pnpm typecheck passes.

Affected areas


3. Add group/zoom-range inputs to the project settings type editors

Goal: A project owner can set a group name (line/area types) and a min/max zoom (all three) on a type from the project settings page, and see the configured range in the type list.

Scope

  • In: The line and area type forms (mirroring the POI type form's existing group input, ~line 299) gain a group text input.
  • In: All three type forms gain minZoom/maxZoom number inputs (both optional/blank-able).
  • In: The three type list views (~lines 786, 888, 980) show the configured range next to each type (e.g. "zoom 4–10") when set.
  • Out: No change to the add-map / add-project flows, and no validation beyond "optional number" (e.g. no enforcement that minZoom <= maxZoom — out of scope, matches the codebase's existing light-touch form validation).

Acceptance criteria

  • Creating or editing a line or area type can set a group name, stored and shown the same way the POI type's group already is.
  • Creating or editing any of the three type kinds can set minZoom/ maxZoom, persisted via the actions from step 2.
  • The type list for all three kinds shows the configured range when set, and nothing extra when unset.
  • pnpm typecheck and pnpm format pass.

Affected areas


4. Add per-instance zoom override inputs

Goal: An individual POI, line, or area can optionally override its type's zoom range (e.g. one especially important dorf that should stay visible zoomed out).

Scope

  • In: The POI/line/area add and edit forms in project-map.tsx gain optional minZoom/maxZoom inputs, submitted alongside the existing fields to the actions updated in step 2.
  • Out: No UI affordance beyond plain number inputs (no visual zoom-range slider).

Acceptance criteria

  • Placing or editing a POI/line/area can set an override minZoom/ maxZoom, left blank by default (falls back to the type's range).
  • pnpm typecheck passes.

Affected areas


5. Filter POIs, lines, and areas on the map by effective zoom range

Goal: A POI/line/area outside its effective zoom range (resolved per step 1) is not rendered, and reappears as soon as the map's zoom re-enters the range — with no visible behavior change for features that have no range configured anywhere.

Scope

  • In: PoiLayer, LineLayer, AreaLayer in map.tsx each track the map's current zoom the same way ImageOverlayLayerView already does (useMap() + useMapEvent("zoomend", …)), and filter their pois/ lines/areas prop through step 1's range-testing function (matching type definition looked up by the instance's type) before rendering.
  • Out: No change to editing/dragging/drawing interactions — a feature being actively edited or drawn is unaffected by this filter (it's either not yet in the pois/lines/areas array, or edited in place).

Acceptance criteria

  • A POI/line/area whose effective range excludes the current zoom is not rendered; it reappears when zoom moves back into range.
  • A feature with no range set anywhere (the common case today) renders at every zoom, unchanged from current behavior.
  • Existing POI/line/area placement, dragging, and editing still work exactly as before.
  • pnpm typecheck passes.

Affected areas


6. Add the custom features visibility control panel

Goal: A user can toggle which POI/line/area types (or whole groups) are shown on the map, from a panel matching the look and behavior of the Avesmaps and Image overlay controls, combined with the step 5 zoom filter.

Scope

  • In: A new component (e.g. app/components/feature-control.tsx) with a hook (e.g. useFeatureVisibilitySelection(mapId, { poiTypes, lineTypes, areaTypes })) that tracks disabled type slugs per kind, persisted to localStorage per map — same "track what's off" shape as useAvesmapsLayerSelection/useImageOverlaySelection.
  • In: The panel UI shows three sections (Points, Lines, Areas), each listing its project's types grouped by group (ungrouped types listed outside any group), with group-level and type-level checkboxes — mirroring AvesmapsLayerControl's group/subtype checkbox structure.
  • In: Wired into project-map.tsx alongside the existing two controls (~lines 1093-1101); <Map> gains isPoiTypeEnabled/isLineTypeEnabled/isAreaTypeEnabled predicate props, ANDed with the step 5 zoom filter inside PoiLayer/LineLayer/AreaLayer.
  • Out: No change to the Avesmaps or Image overlay controls themselves.

Acceptance criteria

  • A new control button opens a panel listing all POI/line/area types grouped by their group field.
  • Toggling a type or a whole group hides/shows matching features on the map immediately.
  • The selection is restored from localStorage on reload, scoped per map id, and never appears in data/projects.json.
  • A feature hidden by the panel stays hidden regardless of zoom; a feature shown by the panel is still subject to the step 5 zoom filter.
  • pnpm typecheck and pnpm format pass.

Affected areas


7. Render feature name labels, scoped by zoom and thinned to avoid clutter

Goal: POIs/lines/areas show their name as a label on the map, visible only within their effective zoom range, with overlapping labels thinned so zoomed-out views (e.g. a world map showing only metropoles) stay readable.

Scope

  • In: Each visible (post step ⅚ filtering) feature renders a permanent label — a non-interactive Tooltip permanent (same primitive as PendingPoiMarker's tooltip) anchored at the POI's position, or the line/area's point-list midpoint.
  • In: Before rendering, labels for the current zoom are sorted by priority (ascending effective minZoom — a lower/no minZoom, e.g. metropole, sorts first) and a label is skipped if its projected pixel position lands within a fixed radius of an already-placed label's position, recomputed on zoomend/moveend.
  • Out: No draggable/manually-positioned labels, no per-instance label text override (always the feature's name), no label styling beyond plain text (no leader lines, halos, or collision-avoiding offset).
  • Out: show-labels-on-map.md is superseded by this step; it is left as a pointer to this document rather than deleted.

Acceptance criteria

  • A feature's name renders as a label when its effective zoom range includes the current zoom, and disappears outside it.
  • Zoomed out enough that only "metropole"-type POIs are in range, only their labels show — no overlapping town/village labels.
  • Zoomed in, labels for lower-priority types appear as they enter range, without needing a manual refresh.
  • At a zoom where many same-priority labels would overlap, at most one per cluster renders (no overlapping text).
  • pnpm typecheck passes.

Affected areas


8. Manual verification

Goal: Confirm the full feature end to end on a real project.

Scope

  • In: Manual check only — no code change.

Acceptance criteria

  • On an existing project (e.g. one already in data/projects.json), every existing POI/line/area still renders exactly as before with no zoom range configured.
  • Setting a dorf-style POI type's minZoom high and a metropole-style type's minZoom low reproduces the "town names only when zoomed in, metropoles always" behavior from the original request.
  • The new control panel's group/type toggles work for all three feature kinds and persist across a reload.
  • pnpm build succeeds.