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 acrosspoi.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'sicon).MapObjectTypeDefinition— the shared type-definition base (everything excepticon).
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 typecheckandpnpm buildpassing onmainbefore starting. - No
data/*.jsonfield-name or shape changes anywhere in this plan —pois/lines/areasarrays 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.tsexportingLatLng,MapObjectKind,MapObject<TType extends string = string>,MapObjectTypeDefinition. - Move app/lib/poi.ts →
app/features/map-objects/poi/poi.ts.PointOfInterestbecomesMapObject<PoiType> & { position: LatLng };PoiTypeDefinitionbecomesMapObjectTypeDefinition & { 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. RenameLineFeature→Line, defined asMapObject<LineType> & { points: LatLng[] }.LineTypeDefinitionbecomes an alias ofMapObjectTypeDefinition.UNKNOWN_LINE_TYPE/DEFAULT_LINE_TYPESunchanged. - Move app/lib/area.ts →
app/features/map-objects/area/area.ts. RenameAreaFeature→Area, same treatment asLine. - 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/AreaFeatureimports and re-exports toLine/Area, and theMapRef.lines/MapRef.areasfield 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.tsand 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. RenameuseFeatureVisibilitySelection→useMapObjectVisibilitySelectionandFeatureControl→MapObjectVisibilityControl. Update its imports toPoiTypeDefinition/LineTypeDefinition/AreaTypeDefinitionfrom their newmap-objectslocations (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.tsxexportsMapObjectForm, replacingPoiForm/LineForm/AreaFormfrom 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 onkind === "poi".app/features/map-objects/map-object-type-form.tsxexportsMapObjectTypeForm, replacingPoiTypeForm/LineTypeForm/AreaTypeFormfrom project.tsx. Samekind-conditional treatment for the icon picker (POI only).- Callers pass
kind="poi" | "line" | "area"plus the per-kindintentstrings ("add" | "update","add-line" | "update-line","add-area" | "update-area", and the mirrored*-typeintents) — 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.tsxexportsMapObjectTypeManager, replacing the three settings-tab sections in project.tsx (grouped type list + inline add/edit viaMapObjectTypeFormfrom Step 4 + delete button). Props includekind: MapObjectKind, the project's types for that kind, and the usage-count helper for that kind.app/features/map-objects/map-object-detail-panel.tsxexportsMapObjectDetailPanel, replacing the three read-only view-mode side panels in project-map.tsx (name, resolved type label, description, close button). Props includekind: MapObjectKind, the object, and its type list (to resolve the label).- Move
distinctGroups,formatZoomRange, and the threecount*Usagehelpers out ofproject.tsxintoapp/features/map-objects/map-object.ts(or a genericcountTypeUsage(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
useMapEditingStatehook →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 namedhandleModeChange,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, exportingEditSidebar. - Extract the edit-mode right panel (switches between add/edit for each kind, each rendering
MapObjectFormfrom Step 4 plus a deletefetcher.Form) →app/features/map-editor/edit-detail-panel.tsx, exportingEditDetailPanel. - The view-mode read-only panels use
MapObjectDetailPanelfrom Step 5 directly. project-map.tsx'sProjectMapcomponent composesuseMapEditingState,EditSidebar,EditDetailPanel,MapObjectDetailPanel×3, and the existingMap/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'sProjectcomponent composes the project settings form (stays inline, per Decisions), the maps list (stays inline), andMapObjectTypeManager(Step 5) ×3, one per kind, inside the existingTabs/TabsContentstructure.
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.
- Check: app/routes/add-map.tsx, app/routes/create.tsx, app/routes/home.tsx, app/routes/login.tsx, app/routes/logout.tsx, app/routes/protected-layout.tsx, app/routes/health.tsx, app/routes/avesmaps-features.tsx, app/routes/avesmaps-feature-detail.tsx.
add-map.tsxwas spot-checked during planning and already fits the pattern (loader, action, one thin component, one inline form) — expect this step to be a confirmation pass, not a rewrite, unless the audit turns up a surprise.
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 fromapp/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 inmap.tsx's JSX/style-object call sites. - Confirm
POI_TYPE_COLORS(inapp/features/map-objects/poi/poi.tsafter 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/*.jsonfield names/shapes, route URLs, or Directus. - Splitting
app/lib/projects.server.tsper domain. - Runtime/user-facing theming (backlog item, not this plan).
- Renaming
PointOfInterest(doesn't collide with "feature", no reason to touch it).