Skip to content

Codebase restructure (v0.0.6)

Target: split the two oversized route files (project-map.tsx, project.tsx) into feature-scoped modules, merge the POI/line/area domains into one map-objects feature built on a shared base type, group the map's view-toggle controls into their own feature, and centralize the visual/registry constants currently duplicated or hardcoded. Pure structural + type-level refactor — no URL or JSON-shape changes (type names do change, see below). Small incidental fixes noticed along the way are allowed but must be called out explicitly in the PR description, not bundled silently.

Naming note (read first)

The codebase already uses "feature" for something unrelated to POI/line/area: docs/architecture/data-model/datasets.md defines dataset features (avesmaps points/routes — AvesmapsPointFeature, AvesmapsRouteFeature, getAvesmapsFeatureDetail), and that term is architecturally load-bearing (see docs/architecture/challenges.md's "feature ids are stable" discussion). Introducing a second, different meaning of "feature" for user-authored POI/line/area annotations would make both senses ambiguous throughout the codebase.

Resolution: the shared POI/line/area base type and its folder are called MapObject / map-objects, not MapFeature. LineFeature and AreaFeature are renamed to Line and Area (dropping "Feature") as part of this; PointOfInterest already doesn't collide and keeps its name. After this plan, "feature" refers exclusively to avesmaps dataset features; "map object" refers exclusively to POI/line/area annotations.

Decisions

Decision Why
POI, line, and area merge into one app/features/map-objects/ folder, built on a shared MapObject<TType> / MapObjectTypeDefinition base type The three concrete shapes are structurally identical today except for geometry (position vs points) and POI's extra icon field — see the Data model section below. One shared base + per-kind files removes that duplication.
Container folder stays app/features/ (not renamed) Confirmed with you; the features/map-objects nesting reads slightly stutter-y but doesn't need a wider rename.
The 3 near-identical instance forms, 3 type forms, 3 type-manager lists, and 3 read-only detail panels each become one generic, kind-parameterized component Confirmed with you: since poi/line/area now share one folder and one base type, the components built on top should share code too, not just sit side by side.
app/lib/feature-zoom.ts moves to app/features/map-objects/zoom.ts (not its own top-level folder) ZoomRange's shape (minZoom/maxZoom/labelMinZoom/labelMaxZoom) mirrors fields already on MapObject/MapObjectTypeDefinition; confirmed with you to nest it rather than give it a sibling top-level folder.
feature-control.tsx, avesmaps-layer-control.tsx, and image-overlay-control.tsx move into one new app/features/map-controls/ folder Confirmed with you: all three are structurally identical view-toggle widgets (a useState + localStorage hook + a Card UI) rendered together as the map's control stack, even though avesmaps-layer-control.tsx otherwise pairs with the avesmaps data domain. Cross-feature type imports (e.g. AvesmapsManifest) from map-controls into avesmaps are expected and fine.
feature-control.tsx / FeatureControl / useFeatureVisibilitySelection rename to map-object-visibility-control.tsx / MapObjectVisibilityControl / useMapObjectVisibilitySelection Same naming-collision reasoning as the MapObject rename above — this control toggles map-object type visibility, not avesmaps "feature" visibility. avesmaps-layer-control.tsx and image-overlay-control.tsx keep their names; neither collides.
app/lib/projects.server.ts stays where it is, unsplit It's one atomic read-modify-write boundary over data/projects.json (a single JSON file, no per-entity transactions) covering Project/Map/Poi/Line/Area/Types. Splitting it per-domain would require either duplicating the file I/O or introducing a shared internal module anyway — not worth the risk for a structural refactor. Its exported type names still need updating (LineFeature→Line, AreaFeature→Area), see Step 1.
map.tsx, map-topbar.tsx stay in app/components Shared map shell, not owned by any single feature.
Project-level settings form (name/description/preview image) and the maps list stay inline in project.tsx Reasonably small (~100 lines combined), genuinely route-specific, not duplicated — extracting them would be scope creep beyond what the oversized-file problem requires.

Data model: the shared MapObject shape

Today, with "Feature" read as "Object" per the rename above:

PointOfInterest         = { id, name, description?, type: PoiType,  position: LatLng,      createdAt, minZoom?, maxZoom?, labelHidden?, labelMinZoom?, labelMaxZoom? }
Line (was LineFeature)  = { id, name, description?, type: LineType, points: LatLng[],       createdAt, minZoom?, maxZoom?, labelHidden?, labelMinZoom?, labelMaxZoom? }
Area (was AreaFeature)  = { id, name, description?, type: AreaType, points: LatLng[],       createdAt, minZoom?, maxZoom?, labelHidden?, labelMinZoom?, labelMaxZoom? }

Every field is identical except geometry (position: LatLng vs points: LatLng[]). The type-definition side is the same story: PoiTypeDefinition/LineTypeDefinition/AreaTypeDefinition are identical except POI's extra icon field. This plan introduces:

  • LatLng = { lat: number; lng: number } — today this shape is inlined at 10+ call sites across poi.ts/line.ts/area.ts/projects.server.ts.
  • MapObjectKind = "poi" | "line" | "area".
  • MapObject<TType extends string = string> — the shared base (everything above except geometry and POI's icon).
  • MapObjectTypeDefinition — the shared type-definition base (everything except icon).

PointOfInterest, Line, Area, PoiTypeDefinition compose from these bases; LineTypeDefinition/AreaTypeDefinition become plain aliases of MapObjectTypeDefinition.

Target tree

app/
  features/
    map-objects/
      map-object.ts                 # LatLng, MapObjectKind, MapObject<T>, MapObjectTypeDefinition
      zoom.ts                       # ZoomRange, resolveZoomRange, isZoomInRange (moved from app/lib/feature-zoom.ts)
      map-object-form.tsx           # MapObjectForm — replaces PoiForm/LineForm/AreaForm
      map-object-type-form.tsx      # MapObjectTypeForm — replaces PoiTypeForm/LineTypeForm/AreaTypeForm
      map-object-type-manager.tsx   # MapObjectTypeManager — replaces the 3 settings-tab list sections
      map-object-detail-panel.tsx   # MapObjectDetailPanel — replaces the 3 read-only view-mode panels
      poi/
        poi.ts                     # PoiType, PoiTypeDefinition, PointOfInterest, palettes/icons (moved from app/lib/poi.ts)
      line/
        line.ts                    # LineType, LineTypeDefinition, Line (moved + renamed from app/lib/line.ts)
      area/
        area.ts                    # AreaType, AreaTypeDefinition, Area (moved + renamed from app/lib/area.ts)
    map-controls/
      map-object-visibility-control.tsx  # MapObjectVisibilityControl, useMapObjectVisibilitySelection (moved + renamed from feature-control.tsx)
      avesmaps-layer-control.tsx          # AvesmapsLayerControl, useAvesmapsLayerSelection (moved, unchanged)
      image-overlay-control.tsx           # ImageOverlayControl, useImageOverlaySelection (moved, unchanged)
    map-editor/
      use-map-editing-state.ts
      edit-sidebar.tsx
      edit-detail-panel.tsx
    avesmaps/
      avesmaps-dataset.ts
      avesmaps-dataset.server.ts
      avesmaps-feature-detail-panel.tsx   # "feature" here is correct — this is an avesmaps dataset feature
    tile-sets/
      tile-sets.ts
    uploads/
      uploads.server.ts
  components/
    map.tsx
    map-topbar.tsx
    ui/
  lib/
    auth.server.ts
    session.server.ts
    directus.server.ts
    env.server.ts
    utils.ts
    projects.server.ts
  routes/
    project-map.tsx    # thin: loader, action, ProjectMap composing map-editor + map-objects generics
    project.tsx         # thin: loader, action, Project composing 3x MapObjectTypeManager
    ...

Prerequisites

  • Clean working tree, pnpm typecheck and pnpm build passing on main before starting.
  • No data/*.json field-name or shape changes anywhere in this plan — pois/lines/areas arrays and their element shapes stay byte-for-byte compatible. Only TypeScript type names change (LineFeature→Line, AreaFeature→Area).

Steps

1. Establish app/features/map-objects/ with the shared base type

Goal: MapObject/MapObjectTypeDefinition/MapObjectKind/LatLng exist in one place, and poi.ts/line.ts/area.ts/feature-zoom.ts move under map-objects/ built on top of them.

  • Create app/features/map-objects/map-object.ts exporting LatLng, MapObjectKind, MapObject<TType extends string = string>, MapObjectTypeDefinition.
  • Move app/lib/poi.ts → app/features/map-objects/poi/poi.ts. PointOfInterest becomes MapObject<PoiType> & { position: LatLng }; PoiTypeDefinition becomes MapObjectTypeDefinition & { icon: string }. All other exports (POI_TYPE_COLORS, POI_TYPE_ICONS, POI_TYPE_ICON_COMPONENTS, POI_TYPE_IMAGE_ICONS, UNKNOWN_POI_TYPE, DEFAULT_POI_TYPES, isPoiImageIcon, makePoiImageIcon, getPoiImageIconPath) are unchanged.
  • Move app/lib/line.ts → app/features/map-objects/line/line.ts. Rename LineFeature → Line, defined as MapObject<LineType> & { points: LatLng[] }. LineTypeDefinition becomes an alias of MapObjectTypeDefinition. UNKNOWN_LINE_TYPE/DEFAULT_LINE_TYPES unchanged.
  • Move app/lib/area.ts → app/features/map-objects/area/area.ts. Rename AreaFeature → Area, same treatment as Line.
  • Move app/lib/feature-zoom.ts → app/features/map-objects/zoom.ts, exports unchanged (ZoomRange, resolveZoomRange, isZoomInRange).
  • In app/lib/projects.server.ts: update the LineFeature/AreaFeature imports and re-exports to Line/Area, and the MapRef.lines/MapRef.areas field types accordingly (field names unchanged).
  • Update every importer: app/routes/project-map.tsx, app/routes/project.tsx, app/components/map.tsx, app/components/feature-control.tsx.

Acceptance criteria - [ ] app/lib/poi.ts, app/lib/line.ts, app/lib/area.ts, app/lib/feature-zoom.ts no longer exist. - [ ] No file imports LineFeature or AreaFeature by those names anywhere in app/. - [ ] PointOfInterest, Line, Area are each defined as MapObject<...> plus their geometry field, not independently. - [ ] pnpm typecheck, pnpm format, pnpm build pass.


2. Move tile-sets, avesmaps, and uploads into their feature folders

Goal: The remaining flat app/lib domain modules move to app/features/<domain>/.

  • Move app/lib/tile-sets.ts → app/features/tile-sets/tile-sets.ts (GridTileSet, XyzTileSet, TileSet, listTileSets, getTileSet).
  • Move app/lib/avesmaps-dataset.ts → app/features/avesmaps/avesmaps-dataset.ts and app/lib/avesmaps-dataset.server.ts → app/features/avesmaps/avesmaps-dataset.server.ts.
  • Move app/components/avesmaps-feature-detail-panel.tsx → app/features/avesmaps/avesmaps-feature-detail-panel.tsx.
  • Move app/lib/uploads.server.ts → app/features/uploads/uploads.server.ts (saveUpload).
  • Update every importer (known: project-map.tsx, project.tsx, add-map.tsx, avesmaps-features.tsx, avesmaps-feature-detail.tsx, map.tsx).

Acceptance criteria - [ ] app/lib/ contains exactly: auth.server.ts, session.server.ts, directus.server.ts, env.server.ts, utils.ts, projects.server.ts. - [ ] No file imports from an old ~/lib/{tile-sets,avesmaps-dataset,avesmaps-dataset.server,uploads.server} path. - [ ] pnpm typecheck, pnpm format, pnpm build pass.


3. Create app/features/map-controls/ and rename FeatureControl

Goal: The three view-toggle map controls live together, and the POI/line/area one no longer says "feature".

  • Move app/components/feature-control.tsx → app/features/map-controls/map-object-visibility-control.tsx. Rename useFeatureVisibilitySelection → useMapObjectVisibilitySelection and FeatureControl → MapObjectVisibilityControl. Update its imports to PoiTypeDefinition/LineTypeDefinition/AreaTypeDefinition from their new map-objects locations (Step 1).
  • Move app/components/avesmaps-layer-control.tsx → app/features/map-controls/avesmaps-layer-control.tsx, unchanged otherwise.
  • Move app/components/image-overlay-control.tsx → app/features/map-controls/image-overlay-control.tsx, unchanged otherwise.
  • Update importers in project-map.tsx.

Acceptance criteria - [ ] app/components/ no longer contains feature-control.tsx, avesmaps-layer-control.tsx, or image-overlay-control.tsx. - [ ] No symbol named FeatureControl or useFeatureVisibilitySelection remains anywhere in app/. - [ ] The map's control stack (top-right of the map view) renders and toggles identically — manual smoke test. - [ ] pnpm typecheck, pnpm format, pnpm build pass.


4. Build the generic MapObjectForm and MapObjectTypeForm

Goal: One kind-parameterized form replaces each of the two form trios.

  • app/features/map-objects/map-object-form.tsx exports MapObjectForm, replacing PoiForm/LineForm/AreaForm from project-map.tsx. Props: kind: MapObjectKind, plus the union of what the three took today (fetcher, intent, hiddenFields, defaultValues, types: MapObjectTypeDefinition[], onCancel). The POI-only icon-picker tab renders conditionally on kind === "poi".
  • app/features/map-objects/map-object-type-form.tsx exports MapObjectTypeForm, replacing PoiTypeForm/LineTypeForm/AreaTypeForm from project.tsx. Same kind-conditional treatment for the icon picker (POI only).
  • Callers pass kind="poi" | "line" | "area" plus the per-kind intent strings ("add" | "update", "add-line" | "update-line", "add-area" | "update-area", and the mirrored *-type intents) — the action's intent strings themselves do not change.

Acceptance criteria - [ ] PoiForm, LineForm, AreaForm, PoiTypeForm, LineTypeForm, AreaTypeForm no longer exist anywhere. - [ ] Add/edit for POI, line, and area instances, and for POI/line/area types, behave identically (manual smoke test covering all six flows, including the POI icon picker). - [ ] pnpm typecheck, pnpm format, pnpm build pass.


5. Build the generic MapObjectTypeManager and MapObjectDetailPanel

Goal: One kind-parameterized list-manager and one kind-parameterized read-only panel replace their respective trios.

  • app/features/map-objects/map-object-type-manager.tsx exports MapObjectTypeManager, replacing the three settings-tab sections in project.tsx (grouped type list + inline add/edit via MapObjectTypeForm from Step 4 + delete button). Props include kind: MapObjectKind, the project's types for that kind, and the usage-count helper for that kind.
  • app/features/map-objects/map-object-detail-panel.tsx exports MapObjectDetailPanel, replacing the three read-only view-mode side panels in project-map.tsx (name, resolved type label, description, close button). Props include kind: MapObjectKind, the object, and its type list (to resolve the label).
  • Move distinctGroups, formatZoomRange, and the three count*Usage helpers out of project.tsx into app/features/map-objects/map-object.ts (or a generic countTypeUsage(project, kind, slug) replacing all three) since they're now shared by one generic manager instead of three call sites.

Acceptance criteria - [ ] PoiDetailPanel/LineDetailPanel/AreaDetailPanel and any per-kind type-manager sections no longer exist as separate components. - [ ] Type management (add/edit/delete/group-switch) for POI/line/area types, and the read-only view-mode panels for selected POI/line/area, behave identically — manual smoke test. - [ ] pnpm typecheck, pnpm format, pnpm build pass.


6. Extract the edit-mode state machine and thin project-map.tsx

Goal: project-map.tsx becomes loader + action + a thin composing component, using the Step 3-5 generics.

  • Extract a useMapEditingState hook → app/features/map-editor/use-map-editing-state.ts, owning the placing/editing/selecting state for all three kinds (mode, per-kind placing/pending/editing/selected state, draft points, selectedDatasetFeatureId) and the handlers currently named handleModeChange, handleTogglePlacing, handleToggleDrawingLine, handleToggleDrawingArea, handleAddLinePoint, handleFinishLineRequest, handleAddAreaPoint, handleFinishAreaRequest, handleSelectRequest, handleLineSelectRequest, handleAreaSelectRequest, handleDatasetFeatureSelectRequest, handleMapClick.
  • Extract the edit-mode left sidebar (add-POI/line/area buttons + Layers/Height Map/Nav Mesh placeholders) → app/features/map-editor/edit-sidebar.tsx, exporting EditSidebar.
  • Extract the edit-mode right panel (switches between add/edit for each kind, each rendering MapObjectForm from Step 4 plus a delete fetcher.Form) → app/features/map-editor/edit-detail-panel.tsx, exporting EditDetailPanel.
  • The view-mode read-only panels use MapObjectDetailPanel from Step 5 directly.
  • project-map.tsx's ProjectMap component composes useMapEditingState, EditSidebar, EditDetailPanel, MapObjectDetailPanel ×3, and the existing Map/MapTopbar/AvesmapsLayerControl/ImageOverlayControl/MapObjectVisibilityControl.

Acceptance criteria - [ ] project-map.tsx exports only loader, action, and the default ProjectMap component; substantially smaller than ~1800 lines (target: under 350, given Steps 4-5 already removed the form/panel bodies). - [ ] View mode and edit mode (place/draw/select/edit/delete for each of POI/line/area, mode-switch state resets) behave identically to before — manual smoke test covering both modes. - [ ] pnpm typecheck, pnpm format, pnpm build pass.


7. Thin project.tsx

Goal: project.tsx becomes loader + action + a thin composing component, using the Step 4-5 generics.

  • project.tsx's Project component composes the project settings form (stays inline, per Decisions), the maps list (stays inline), and MapObjectTypeManager (Step 5) ×3, one per kind, inside the existing Tabs/TabsContent structure.

Acceptance criteria - [ ] project.tsx no longer defines any *TypeForm, *TypeManager, or per-kind usage-counting helper. - [ ] Substantially smaller than ~1400 lines (target: under 350, given Steps 4-5 already removed the bulk). - [ ] pnpm typecheck, pnpm format, pnpm build pass.


8. Audit remaining routes

Goal: Confirm every other route file already follows "loader + action + thin component"; fix any that don't.

Acceptance criteria - [ ] Each listed route is confirmed (in the PR description) to already comply, or is brought into compliance. - [ ] pnpm typecheck, pnpm format, pnpm build pass.


9. Centralize scattered visual/registry constants

Goal: Hardcoded map-chrome colors are defined exactly once, discoverable from one place.

  • In app/components/map.tsx, replace the hardcoded hex literals used for map chrome (selection halo, draw-preview stroke, delete-marker glyphs — currently "#334155", "#3b82f6", "#dc2626", "#9ca3af") with named constants exported from app/features/map-objects/map-object.ts (or a sibling module — exact name is the planner's/implementer's call), so no raw hex literal remains inline in map.tsx's JSX/style-object call sites.
  • Confirm POI_TYPE_COLORS (in app/features/map-objects/poi/poi.ts after Step 1) and the equivalent line/area palettes are each defined exactly once and re-exported, not redefined, anywhere they're consumed.
  • Confirm app/features/tile-sets/tile-sets.ts's static registry remains the single source for tile-set metadata (no duplicate tile-set definitions elsewhere).

Acceptance criteria - [ ] Searching for raw hex-color literals (#[0-9a-f]{3,6}) in app/components/ and app/routes/ returns no matches outside app/app.css and the new constants module(s). - [ ] pnpm typecheck, pnpm format, pnpm build pass.

Out of scope

  • Any change to data/*.json field names/shapes, route URLs, or Directus.
  • Splitting app/lib/projects.server.ts per domain.
  • Runtime/user-facing theming (backlog item, not this plan).
  • Renaming PointOfInterest (doesn't collide with "feature", no reason to touch it).