Skip to content

Map Measurement Tool - Implementation Plan

Decisions

Decision Choice Why
Measurement lifetime Temporary client-side overlays only Measurements are an inspection aid, not authored map objects; they must not change project data or require a new persistence model.
Control placement Dedicated measurement control in the map view Measurement input is distinct from creating and editing saved lines and areas.
Measurement modes Distance and area through leaflet-measure The requested plugin supplies both workflows, result formatting, and measurement lifecycle events.
Units Metric: meters/kilometers for length and square meters/hectares for area Matches the product decision and keeps the first release consistent across maps.
Calibration Optional per-map real-world scale in existing Map settings Uploaded and grid maps use CRS.Simple; a persisted map calibration is required for meaningful real-world results.
Calibration representation Store the map-unit-to-real-world conversion on MapRef, with an explicit metric unit The value belongs to the map source and must work through both local JSON and the existing Directus project payload.
Existing objects Measurement overlays do not become POIs, lines, or areas Avoids mixing transient measurement state with saved map-object editing, selection, and server actions.
Directus schema No separate Directus field is assumed No directus/schema/*.json snapshot exists; the verified persistence boundary is the existing map_projects.data project payload. Deployment compatibility must be checked.

Prerequisites

  • Keep the existing protected route declaration in app/routes.ts: project/:projectId/map/:mapId continues to render app/routes/project-map.tsx.
  • Preserve the server boundary: map calibration persistence belongs in app/lib/projects.server.ts; the route action must use activeAccessToken(context) before calling it.
  • Keep calibration optional so existing MapRef records without the new value continue to load and measurements can fall back to the current plugin coordinate behavior until configured.
  • Add leaflet-measure through pnpm and verify its package version, browser bundle, stylesheet, and TypeScript availability. If it has no usable declarations, add a narrow local declaration rather than weakening project-wide TypeScript settings.
  • Keep Leaflet and leaflet-measure client-only. The wrapper must follow the existing useLeaflet/lazyMapComponent approach and must not import the plugin at the top level of an SSR route module.
  • Use MapControlContainer as the event-isolation boundary for any custom React trigger or wrapper, following the shadcn-map custom-control guidance.
  • The plugin's measurestart and measurefinish events are the integration points. measurefinish exposes length, area, points, and formatted display values; no server action is needed for those results.
  • No new route or loader is required. No Directus collection or top-level field may be introduced without deployment schema confirmation.

Step 1 - Extend the per-map calibration contract

Goal: Represent optional real-world measurement calibration on an individual map without breaking existing projects.

Scope

  • In: app/lib/projects.server.ts, extend MapRef with an optional calibration value and metric unit using a small named type; keep the field optional for backward compatibility.
  • In: app/lib/projects.server.ts, normalize or ignore malformed legacy calibration values while loading local JSON and Directus project rows; project loading must not throw because of an invalid optional setting.
  • In: app/lib/projects.server.ts, add a focused map-settings mutation that updates only the targeted map's calibration and writes through the existing local/Directus project-row path.
  • In: data/projects.json, do not perform a bulk migration; maps without calibration remain valid.
  • Out: A new measurement collection, saved measurement geometries, project-wide calibration, or a new route.

Acceptance criteria

  • Existing projects and maps without calibration load successfully.
  • Calibration is stored on the targeted MapRef only.
  • Updating calibration preserves the map source, default view, objects, and image overlays.
  • Missing project or map ids return an absent result that the route can convert to 404.
  • Local JSON and Directus-backed storage use the existing project-row write path.
  • pnpm typecheck passes.

Step 2 - Add protected calibration validation and Map settings UI

Goal: Let authorized map editors configure a valid calibration from the existing settings surface.

Scope

  • In: app/routes/project-map.tsx, extend the existing settings action intent to parse calibration value and unit alongside the existing default-view fields.
  • In: app/routes/project-map.tsx, reject non-finite, zero, or negative calibration values and reject unsupported units; return field-level errors without writing any invalid calibration.
  • In: app/routes/project-map.tsx, call the calibration persistence function with activeAccessToken(context) and return 404 for a missing project or map.
  • In: the existing Map settings form in app/routes/project-map.tsx, add a clearly labeled calibration field and metric unit selector; preserve the existing default-view fields and form behavior.
  • In: use existing Field, Input, Select or equivalent UI primitives; keep the form usable on narrow viewports.
  • Out: Editing map source, calibration history, per-measurement unit switching, or a separate settings route.

Acceptance criteria

  • The existing protected settings action remains the only write entry point.
  • The action authenticates through activeAccessToken(context) before persistence.
  • Invalid calibration values never reach the server persistence function.
  • Field errors remain visible after rejected submissions and valid default-view values are not discarded.
  • A valid calibration saves and reloads with the map.
  • Maps without calibration remain usable and clearly communicate that measurement results are uncalibrated or use map units, according to the final UI wording.
  • pnpm typecheck passes.

Step 3 - Add the client-only Leaflet measurement wrapper

Goal: Wrap leaflet-measure as a React map component with deterministic lifecycle and cleanup behavior.

Scope

  • In: add a focused module under app/components/map/ for the measurement control or hook, following the imperative plugin pattern already used by MapFullscreenControl and MapImperativeLayer.
  • In: load the plugin only after mount and after Leaflet is available; extend the existing client-only loader only as needed, without importing browser-dependent plugin code from the route module.
  • In: instantiate one L.Control.Measure per mounted map, configure metric primary length/area units, apply the map calibration, and add/remove it with the React component lifecycle.
  • In: subscribe and unsubscribe to measurestart and measurefinish; keep results transient and expose no server persistence callback. The wrapper may expose a typed completion callback for future UI integration, but must not retain measurement history in project state.
  • In: ensure repeated renders do not create duplicate controls or duplicate event handlers; cleanup must remove the control and listeners.
  • Out: Reimplementing measurement geometry, storing measurement results, or converting plugin output through ad hoc string parsing.

Acceptance criteria

  • The plugin is never evaluated during SSR.
  • Mounting the component adds exactly one measurement control to the current Leaflet map.
  • Unmounting removes the control, event handlers, and plugin-created map layers.
  • Activating distance or area measurement does not interfere with normal map input after the tool is stopped.
  • Configured calibration is passed through a typed, deterministic option path; absent calibration has documented fallback behavior.
  • pnpm typecheck passes.

Step 4 - Compose the dedicated control in the map route

Goal: Make measurement available on both tile-set and uploaded maps without coupling it to saved-object editing.

Scope

  • In: app/routes/project-map.tsx, add a lazy map-component import for the measurement wrapper and render it as a child of the existing Map composition.
  • In: pass the current map calibration and any required map-source context from loader data; do not add a loader because calibration is already part of MapRef.
  • In: place any React trigger or custom control shell inside MapControlContainer, using a stable position that does not overlap the existing topbar, fullscreen, zoom, or layer controls.
  • In: keep the measurement control available in view mode and define behavior when entering edit/settings mode so it cannot capture clicks intended for saved-object editing or the settings form.
  • In: preserve existing LineLayer, AreaLayer, MapClickHandler, object selection, visibility controls, and map view persistence behavior.
  • Out: Adding measurement results to the editor sidebar, changing drawing tools, changing route declarations, or changing map-object actions.

Acceptance criteria

  • Both tile-set and upload-backed maps render the measurement control.
  • The control is visually and behaviorally separate from saved line/area creation.
  • Map control interactions do not pan, zoom, or trigger map clicks unintentionally.
  • Measurement activation does not create, select, edit, or persist a POI, line, or area.
  • Switching away from view mode leaves no active measurement capture behind.
  • pnpm typecheck passes.

Step 5 - Style and document measurement results

Goal: Make the plugin UI and temporary measurement layers match the application's map styling and remain legible across themes and viewport sizes.

Scope

  • In: app/app.css, import or include the plugin stylesheet through the existing asset strategy and add narrowly scoped overrides for control, tooltip, popup, active geometry, completed geometry, vertices, and result layers.
  • In: reuse the application's existing color variables and typography; do not introduce a separate visual language for the plugin.
  • In: ensure plugin result popups and controls have readable contrast, bounded dimensions, and no overlap with existing map controls or viewport edges.
  • In: preserve the plugin's semantic class names such as layer-measurearea, layer-measureboundary, layer-measure-resultarea, and layer-measure-resultline when styling temporary geometry.
  • Out: Global Leaflet restyling unrelated to measurement, redesigning existing controls, or adding a measurement history panel.

Acceptance criteria

  • The measurement control is legible in the application's supported themes.
  • Distance and area result popups fit their content without clipping or overlapping neighboring controls.
  • Active and completed measurement geometry is visually distinct from saved map objects and drawing previews.
  • Narrow viewport checks show no overlapping buttons, labels, or popups.
  • pnpm format completes successfully.

Step 6 - Verify calibration, lifecycle, and non-persistence

Goal: Confirm the complete feature works across map sources while preserving existing editing and persistence behavior.

Acceptance criteria

  • pnpm typecheck passes.
  • pnpm format completes successfully.
  • pnpm build passes.
  • Manual check: configure a positive metric calibration in Map settings, reload the map, measure a two-point distance, and confirm the result uses metric distance units.
  • Manual check: measure a polygon with at least three points and confirm the result uses metric area units.
  • Manual check: open a map without calibration and confirm the documented fallback appears without a broken control or misleading persisted value.
  • Manual check: repeat distance and area measurement on both a tile-set map and an uploaded CRS.Simple map.
  • Manual check: start and cancel measurement, then pan, zoom, select objects, and create/edit a saved line or area; confirm normal interactions remain intact.
  • Manual check: leave view mode or unmount the map, return to it, and confirm prior temporary measurement overlays are gone.
  • Manual check: inspect data/projects.json or the configured Directus project payload after measuring and confirm no measurement geometry or result was persisted.
  • Manual check: submit invalid calibration values and confirm field errors with unchanged saved settings.
  • Confirm no new directus/schema/*.json field was assumed without deployment confirmation.

Risks / open questions

  • No directus/schema/*.json snapshot exists in this workspace. The implementation must verify that the deployed map_projects.data payload accepts the optional nested calibration value; do not create a separate Directus field as a workaround without approval.
  • leaflet-measure is an older Leaflet plugin and may not ship declarations or modern module behavior. The implementation should isolate any compatibility declaration or dynamic import to the measurement wrapper and verify the actual package export before coding against it.
  • CRS.Simple coordinates are not inherently real-world units. The calibration contract must define exactly what one map coordinate represents and must be applied consistently to both length and area; the UI should make the absence of calibration explicit.
  • Existing Leaflet drawing and measurement tools both capture map clicks. The route must prevent simultaneous activation and clean up measurement capture when mode changes.
  • The plugin creates its own result layers and popups. Cleanup and CSS overrides need manual verification because React does not own those DOM nodes directly.

References