11<template >
22 <!--
3- Interactive MapLibre GL map for the project detail page.
4-
5- Responsibilities:
63 - Renders the Area of Interest (AOI) as a semi-transparent fill with a dashed outline.
74 - Renders individual project tasks as coloured polygons (colour = task status).
85 - Renders a task-grid preview (used during the task-setup workflow).
@@ -84,30 +81,19 @@ import type {
8481} from ' ~/types/project-wizard' ;
8582
8683interface Props {
87- /** The project's area-of-interest polygon. Null when the project has no AOI yet. */
8884 aoi: WorkspaceProjectAoiFeature | null ;
89- /** The task ID that is currently highlighted/selected. */
9085 selectedTaskId? : string | null ;
91- /**
92- * Optional task grid to show on the map. Used in two scenarios:
93- * - Preview grid (task-setup workflow, before tasks are saved)
94- * - Persisted task grid (built from saved task geometries on the detail page)
95- */
9686 taskGrid? : ProjectWizardGeneratedTaskFeatureCollection | ProjectWizardTaskPreviewFeatureCollection | null ;
97- /** Saved project tasks. Each task has geometry + status used to colour the polygon. */
9887 tasks: WorkspaceProjectTaskListItem [];
9988}
10089
10190const props = defineProps <Props >();
10291const emit = defineEmits <{
103- /** Fired when the user clicks on a task polygon. The parent should update `selectedTaskId`. */
10492 ' select-task' : [taskId : string ];
10593}>();
10694
107- /** Ref to the div that MapLibre will mount into. */
10895const mapRef = useTemplateRef <HTMLDivElement >(' mapRef' );
10996
110- // ─── MapLibre Source & Layer IDs ─────────────────────────────────────────────
11197const AOI_SOURCE_ID = ' project-detail-aoi' ;
11298const AOI_OUTLINE_SOURCE_ID = ' project-detail-aoi-outline' ;
11399const TASK_SOURCE_ID = ' project-detail-tasks' ;
@@ -118,14 +104,8 @@ const TASK_FILL_ID = 'project-detail-task-fill';
118104const TASK_LINE_ID = ' project-detail-task-line' ;
119105const TASK_GRID_FILL_ID = ' project-detail-task-grid-fill' ;
120106const TASK_GRID_LINE_ID = ' project-detail-task-grid-line' ;
121-
122- /** Default map center (Seattle) shown when no AOI/tasks are available yet. */
123107const DEFAULT_CENTER: [number , number ] = [- 122.3321 , 47.6062 ];
124108
125- /**
126- * Minimal MapLibre style that only loads OpenStreetMap raster tiles.
127- * We don't use a hosted style (e.g. MapTiler) to keep things self-contained and free.
128- */
129109const baseMapStyle: StyleSpecification = {
130110 version: 8 ,
131111 sources: {
@@ -146,48 +126,31 @@ const baseMapStyle: StyleSpecification = {
146126 ],
147127};
148128
149- /**
150- * A reusable empty FeatureCollection used to initialise MapLibre sources.
151- * Sources must have valid GeoJSON at creation time, so we give them this placeholder
152- * and update them later via `setData()` once real data is available.
153- */
154129const emptyCollection: FeatureCollection = {
155130 type: ' FeatureCollection' ,
156131 features: [],
157132};
158133
159- /** Colours to display in the legend — must stay in sync with the MapLibre paint expressions below. */
160134const legendItems = [
161135 { label: ' Ready for mapping' , color: ' #fde9aa' },
162136 { label: ' Ready for validation' , color: ' #a8d8f8' },
163137 { label: ' More mapping needed' , color: ' #f8be90' },
164138 { label: ' Completed' , color: ' #aae8cd' },
165139];
166140
167- /**
168- * Module-level references to the MapLibre instances.
169- * These are intentionally NOT Vue refs because MapLibre manages its own internal state —
170- * wrapping the map object in Vue's reactivity system would cause unnecessary overhead.
171- * We only interact with them through regular function calls.
172- */
173141let maplibregl: typeof import (' maplibre-gl' ) | null = null ;
174142let detailMap: import (' maplibre-gl' ).Map | null = null ;
175- /** Holds references to DOM Marker instances for locked tasks so we can remove them on update. */
176143let lockedTaskMarkers: Marker [] = [];
177144let mapResizeObserver: ResizeObserver | null = null ;
145+ const mapReady = ref (false );
146+ let hasFittedInitialBounds = false ;
178147
179- /** Show the legend only when there's something meaningful to show on the map. */
180148const showLegend = computed (() =>
181149 Boolean (props .aoi )
182150 || props .tasks .length > 0
183151 || Boolean (props .taskGrid ?.features .length ),
184152);
185153
186- /**
187- * Convert the task list items into a GeoJSON FeatureCollection that MapLibre can render.
188- * Each feature carries `status`, `selected`, and `locked` properties which are used
189- * by the paint expressions on the task fill/line layers to drive colours and widths.
190- */
191154const taskFeatureCollection = computed <FeatureCollection <Polygon >>(() => ({
192155 type: ' FeatureCollection' ,
193156 features: props .tasks
@@ -205,11 +168,6 @@ const taskFeatureCollection = computed<FeatureCollection<Polygon>>(() => ({
205168 })),
206169}));
207170
208- /**
209- * The AOI polygon converted to LineStrings for the dashed-outline layer.
210- * We use a separate source for the outline because MapLibre doesn't support
211- * dashed fill-outlines directly — you need a dedicated `line` layer.
212- */
213171const aoiOutlineFeatureCollection = computed <FeatureCollection <LineString | MultiLineString >>(() => ({
214172 type: ' FeatureCollection' ,
215173 features: props .aoi ? createOutlineFeatures (props .aoi .geometry ) : [],
@@ -237,10 +195,8 @@ const lockedTaskMarkersData = computed(() =>
237195);
238196
239197/**
240- * Lazy-load MapLibre GL on mount so it doesn't bloat the initial page bundle.
241- * The map is only needed when this component is actually rendered, not on every page.
242198 *
243- * Setup order matters :
199+ * Setup order:
244200 * 1. Create the Map instance.
245201 * 2. Add the navigation control (zoom buttons).
246202 * 3. Wait for the `load` event — sources/layers can only be added after the style is ready.
@@ -268,23 +224,46 @@ onMounted(async () => {
268224 bindInteractionHandlers ();
269225 syncSources ();
270226 fitMapToData ();
227+ mapReady .value = true ;
228+ setTimeout (() => {
229+ if (! detailMap ) return ;
230+ syncSources ();
231+ fitMapToData ();
232+ }, 300 );
271233 });
272234
273235 mapResizeObserver = new ResizeObserver (() => {
274- detailMap ?.resize ();
236+ if (! detailMap ) return ;
237+ detailMap .resize ();
238+ const container = detailMap .getContainer ();
239+ if (container .clientWidth > 0 && container .clientHeight > 0 && ! hasFittedInitialBounds && mapReady .value ) {
240+ fitMapToData ();
241+ }
275242 });
276243 mapResizeObserver .observe (mapRef .value );
277244});
278245
279246/**
280247 * When any of the key data props change (AOI, tasks, task grid), push the updated
281248 * FeatureCollections into the MapLibre sources and re-fit the viewport.
282- * `deep: true` is required here because the FeatureCollection objects are complex nested structures.
283- */
249+ *
250+ * */
284251watch (
285- () => [props .aoi , taskFeatureCollection .value , props .taskGrid ],
252+ [
253+ () => props .aoi ,
254+ () => props .tasks ,
255+ () => props .taskGrid ,
256+ () => taskFeatureCollection .value ,
257+ () => mapReady .value ,
258+ ],
286259 () => {
287- detailMap ?.resize ();
260+ // Whenever props change, reset the fit flag so we attempt to fit the new data
261+ hasFittedInitialBounds = false ;
262+
263+ if (! mapReady .value ) {
264+ return ;
265+ }
266+
288267 syncSources ();
289268 fitMapToData ();
290269 },
@@ -304,8 +283,6 @@ onBeforeUnmount(() => {
304283});
305284
306285/**
307- * Create all MapLibre sources and layers exactly once, after the style has loaded.
308- * We guard with `getSource(AOI_SOURCE_ID)` so this is idempotent — calling it twice is safe.
309286 *
310287 * Layer order (painter's algorithm — later layers paint over earlier ones):
311288 * aoiFill → taskGridFill → taskGridLine → taskFill → taskLine → aoiLine
@@ -438,11 +415,6 @@ function ensureLayers() {
438415 detailMap .addLayer (aoiLineLayer );
439416}
440417
441- /**
442- * Wire up mouse-enter/leave (cursor changes) and click (task selection) events.
443- * We listen on both the fill AND line layers because a click on the border of two
444- * tasks might only hit the line layer, not the fill.
445- */
446418function bindInteractionHandlers() {
447419 if (! detailMap ) {
448420 return ;
@@ -455,16 +427,9 @@ function bindInteractionHandlers() {
455427 detailMap .on (' click' , TASK_FILL_ID , handleTaskClick );
456428 detailMap .on (' click' , TASK_LINE_ID , handleTaskClick );
457429}
458-
459- /**
460- * Push the latest data from props/computed values into the MapLibre GeoJSON sources.
461- * This is called after the style loads (initial render) and every time watched props change.
462- *
463- * We guard with `isStyleLoaded()` because `setData()` will throw if called before the
464- * style is ready — for example, if props change before the async `load` event fires.
465- */
430+ // sync the computed FeatureCollections into the MapLibre sources so they render on the map.
466431function syncSources() {
467- if (! detailMap || ! detailMap . isStyleLoaded () ) {
432+ if (! detailMap ) {
468433 return ;
469434 }
470435
@@ -473,10 +438,14 @@ function syncSources() {
473438 const taskSource = detailMap .getSource (TASK_SOURCE_ID ) as GeoJSONSource | undefined ;
474439 const taskGridSource = detailMap .getSource (TASK_GRID_SOURCE_ID ) as GeoJSONSource | undefined ;
475440
476- aoiSource ?.setData (props .aoi ?? emptyCollection );
477- aoiOutlineSource ?.setData (aoiOutlineFeatureCollection .value );
478- taskSource ?.setData (taskFeatureCollection .value );
479- taskGridSource ?.setData (props .taskGrid ?? emptyCollection );
441+ if (! aoiSource || ! aoiOutlineSource || ! taskSource || ! taskGridSource ) {
442+ return ;
443+ }
444+
445+ aoiSource .setData (props .aoi ?? emptyCollection );
446+ aoiOutlineSource .setData (aoiOutlineFeatureCollection .value );
447+ taskSource .setData (taskFeatureCollection .value );
448+ taskGridSource .setData (props .taskGrid ?? emptyCollection );
480449
481450 // Once tasks or a generated grid exist, remove the AOI wash so the task cells stay legible.
482451 const hasTaskOverlay = taskFeatureCollection .value .features .length > 0
@@ -486,18 +455,16 @@ function syncSources() {
486455 syncLockedTaskMarkers ();
487456}
488457
489- /**
490- * Zoom and pan the map to frame all available data.
491- * Priority: task bounds > task-grid bounds > AOI bounds > default center.
492- * On the tasks tab, real task geometry is the most important context — if we fit to a large AOI
493- * first, newly created tasks can be technically present but visually imperceptible on initial load.
494- * If nothing is available yet, we just ease back to the default Seattle center.
495- */
496458function fitMapToData() {
497459 if (! detailMap ) {
498460 return ;
499461 }
500462
463+ const container = detailMap .getContainer ();
464+ if (container .clientWidth === 0 || container .clientHeight === 0 ) {
465+ return ;
466+ }
467+
501468 const aoiBounds = props .aoi ? getFeatureBounds (props .aoi .geometry ) : null ;
502469 const taskBounds = taskFeatureCollection .value .features .length > 0
503470 ? getFeatureCollectionBounds (taskFeatureCollection .value )
@@ -523,13 +490,10 @@ function fitMapToData() {
523490 maxZoom: 14 ,
524491 essential: true ,
525492 });
493+
494+ hasFittedInitialBounds = true ;
526495}
527496
528- /**
529- * Returns the padding (in px) to apply when fitting the map to feature bounds.
530- * Expressed as an object so we can later make per-side adjustments if the UI changes
531- * (e.g. a side-panel overlapping the left side of the map).
532- */
533497function resolvePadding(): PaddingOptions {
534498 return {
535499 top: 64 ,
@@ -748,7 +712,6 @@ function handleTaskMouseLeave() {
748712/**
749713 * Emit the clicked task's ID to the parent page.
750714 * We validate that the feature has a non-empty string `id` property before emitting
751- * to avoid passing null/undefined IDs when the user clicks on the map background.
752715 */
753716function handleTaskClick(event : MapLayerMouseEvent ) {
754717 const clickedTask = event .features ?.[0 ]?.properties ?.id ;
0 commit comments