Skip to content

Add circular MiniMap widget - #2852

Open
ArturoManzoli wants to merge 10 commits into
bluerobotics:masterfrom
ArturoManzoli:2602-round-shape-faded-edges
Open

Add circular MiniMap widget#2852
ArturoManzoli wants to merge 10 commits into
bluerobotics:masterfrom
ArturoManzoli:2602-round-shape-faded-edges

Conversation

@ArturoManzoli

@ArturoManzoli ArturoManzoli commented Jul 17, 2026

Copy link
Copy Markdown
Contributor
  • Add a compact, always-centered circular map with faded edges for an at-a-glance situational view, with no on-map controls.
  • Tracks either the vehicle or a chosen dynamic POI; vehicle tracking can rotate the map so the vehicle points up, while POI tracking stays north-up.
  • Clickable north indicator animates the map between heading-up and north-up.
  • Off-screen points of interest show as arrows pinned around the circular edge.
  • Right-click menu quick-toggles: show/hide mission, vehicle path, north indicator, and POIs.
  • Draggable by a handle without entering edit mode; dims with a "Vehicle offline" / "No POI selected" notice when there is nothing to track.
  • Map provider selector and edge-fade amount configurable in the widget settings.
  • The mission line and the vehicle trail move into two shared composables that the map widget and mission planning now use too, replacing the polyline code duplicated in both.

Two existing surfaces change behavior as a result:

  • The map widget's "Show vehicle path" switch starts working. It has never been read, so the trail was always drawn; anyone who switched it off and saw no effect will find the trail gone after upgrading. Default stays on.
  • The mission line in mission planning changes color, adopting the shared mission-path blue over Leaflet's default, so both surfaces draw the same line.

leaflet-rotate is GPL-3.0 and is bundled in both distributions; see the third-party components table added to the README.

MiniMap overview

Image 2 - North indicator switched to 'North-up', instead of the default 'vehicle's heading-up'
MiniMap close-up

Image 3 - Custom context menu; quick-toggles (1) for mission, vehicle path, north indicator and POIs; (2) points at the clickable north indicator that animates between heading-up and north-up.
MiniMap context menu

Image 4 - Widget settings: the Tracking target selector (1) switches between following the vehicle or a dynamic POI (POI tracking stays north-up), with map provider and edge-fade options above.
MiniMap settings

Closes #2602

@ArturoManzoli ArturoManzoli changed the title Widgets: Add circular heading-up MiniMap widget Widgets: Add circular MiniMap widget Jul 17, 2026
@github-actions

Copy link
Copy Markdown

Automated PR Review (Claude)

0. Summary

Verdict: MINOR SUGGESTIONS

Minor items to address: 1.1, 2.1, 4.1

This PR adds a new circular MiniMap widget with heading-up rotation (via the leaflet-rotate plugin), vehicle/POI tracking, configurable edge fade, and a right-click context menu. The implementation is well-structured: domain geometry lives in src/libs/map/minimap-geometry.ts (with tests), Leaflet orchestration in src/composables/map/useMiniMap.ts, and vector layers in reusable composables. Vehicle telemetry is correctly read from the data lake; only non-telemetry app state (vehicleType, isVehicleOnline) comes from the vehicle store. Widget options use the default-merging pattern. The commit history is clean and logical.

1. Correctness & Implementation Bugs

1.1 (minor) — Empty JSDoc block on the globalThis property
src/composables/map/useMiniMap.ts (around line 22–24 in the new file) contains an empty JSDoc:

        /**
         *
         */
        L?: typeof L

AGENTS.md explicitly forbids JSDoc blocks whose summary is empty or whitespace-only ("Never write a JSDoc whose summary line is empty, whitespace-only, or filler"). Because this is inside a cast expression on a property signature, it is technically a TSPropertySignature context where jsdoc/require-jsdoc applies. Either give it a real description (e.g. /** Leaflet global used by the leaflet-rotate plugin */) or remove the block entirely.

2. AGENTS.md Adherence

2.1 (minor) — New dependency leaflet-rotate: verify maintenance & alphabetical placement
leaflet-rotate (^0.2.8) is a new runtime dependency. Its placement in package.json is alphabetically correct (between leaflet-edgebuffer and leaflet.offline). The package is a niche Leaflet plugin (npm: ~70 weekly downloads) with infrequent updates. This is acceptable for a Leaflet plugin, but worth noting the maintenance risk. The lazy-load + global-L shim pattern in useMiniMap.ts is a reasonable workaround for the plugin's architecture; just be aware that a Leaflet major-version bump may break it.

Rule: "Before adding a new dependency, check the packages.json file."

3. Security — ✅

4. Performance

4.1 (minor) — mouseup/dragend listeners on window may leak if the component unmounts mid-drag
In MiniMap.vue, enableMovingOnDrag registers mouseup and dragend handlers on window. If the widget is removed while a drag is in progress (e.g. switching views), disableMovingOnDrag never fires and the listeners remain. Consider cleaning them up in onBeforeUnmount:

onBeforeUnmount(() => {
  window.removeEventListener('mouseup', disableMovingOnDrag)
  window.removeEventListener('dragend', disableMovingOnDrag)
})

The composable's own onBeforeUnmount(destroy) in useMiniMap.ts is correct and covers the map/observer teardown.

5. UI / UX — ✅

6. Code Quality & Style

Clean — no findings.

— ✅

7. Commit Hygiene — ✅

8. Tests — ✅

9. Documentation — ✅

10. Nitpicks / Optional

10.1 (nit) — The WidgetManagerVars type now has contextMenuItems?: ContextMenuItem[] as optional, and defaultWidgetManagerVars adds contextMenuItems: []. Making the default explicit is good, but consider whether the ? on the type is needed — since the default always provides [], the property will always exist on newly created vars. If you want to keep backward-compatibility with existing persisted vars that lack the key, the ?? [] guard in WidgetHugger.vue already handles that, so both approaches work fine.

10.2 (nit) — In PoiMapArrows.vue, the POI circle arrow sets angle: circleArrow.angleDeg + 90 while the target arrow uses angle: circleArrow.angleDeg without the offset. If both use the same arrow template, verify the 90° offset is intentional for POI icons vs. vehicle/home indicators.

Generated by Claude. This is advisory; a human reviewer must still approve.

@ArturoManzoli
ArturoManzoli force-pushed the 2602-round-shape-faded-edges branch from 5ff7362 to 86630a7 Compare July 17, 2026 18:47
@ArturoManzoli

Copy link
Copy Markdown
Contributor Author

Done:

  • (1.1) gave the leaflet-rotate global-L cast a real one-line description instead of the empty jsdoc block
  • (4.1) added an onBeforeUnmount that removes the window mouseup/dragend drag listeners, so they can't leak if the widget unmounts mid-drag

Not addressed:

  • (2.1) leaflet-rotate's placement is already alphabetically correct and the finding is only a note about upstream maintenance risk, so there's no code change to make
  • (10.1) the ? on contextMenuItems is intentional for backward-compat with persisted vars created before the key existed; the ?? [] guard in WidgetHugger.vue already covers that case
  • (10.2) the + 90 offset is intentional and matches the existing rectangle POI arrow path (angle + 90); the target/home arrow deliberately omits it, so POI icons and target indicators keep their correct orientations

@ArturoManzoli

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Re-review 2 (Claude)

Comparing 5ff736236244f8f7549942cfec4a14674c03c2d486630a70b12c559e459eb80cd8e4890b38bd9850

Previous findings status

# Finding Severity Status
1.1 Empty JSDoc block on globalThis L property in useMiniMap.ts minor ✅ Addressed
2.1 New dependency leaflet-rotate: maintenance risk note minor ⚪ No longer applicable
4.1 mouseup/dragend listeners on window may leak on unmount minor ✅ Addressed
10.1 Optional ? on contextMenuItems type nit ⚪ No longer applicable
10.2 90° angle offset on POI vs target arrows nit ⚪ No longer applicable

Discussion since last review

@ArturoManzoli confirmed that 1.1 and 4.1 were fixed, while 2.1 was acknowledged as informational only, 10.1 is intentional for backward-compat with persisted vars, and 10.2's + 90 offset matches the existing rectangle arrow behavior. All explanations are reasonable; the status table above reflects these outcomes.

New findings

0. Summary

Verdict: READY TO MERGE

Both actionable findings from the initial review (1.1 empty JSDoc, 4.1 listener leak) have been addressed. The informational and nit-level findings were satisfactorily explained by the author. The incremental diff shows no regressions or new issues — the only change in the final commit is the JSDoc fix and the onBeforeUnmount cleanup, both of which are correct. The PR remains well-structured with clean commit hygiene.

1. Correctness & Implementation Bugs — ✅

2. AGENTS.md Adherence — ✅

3. Security — ✅

4. Performance — ✅

5. UI / UX — ✅

6. Code Quality & Style — ✅

7. Commit Hygiene — ✅

8. Tests — ✅

9. Documentation — ✅

10. Nitpicks / Optional — ✅

Generated by Claude. This is advisory; a human reviewer must still approve.

@ArturoManzoli

Copy link
Copy Markdown
Contributor Author

Ready to go.

@ArturoManzoli ArturoManzoli changed the title Widgets: Add circular MiniMap widget Add circular MiniMap widget Jul 17, 2026

@rafaellehmkuhl rafaellehmkuhl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before I start the review, I believe it makes sense to use a different image for this widget. A circular one and with a different position in the map would be good.

Image

@rafaellehmkuhl rafaellehmkuhl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Besides the widget icon thing, the only addition I have is that new map vector layer composables (useMapMissionLayer / useMapVehiclePathLayer / useMapVectorLayer) were created and should also be wired into the main Map widget and Mission Planning view — otherwise we just grew a second copy of that drawing path for this new widget.

Apart from that this new widget looks NEAT, super cool, super useful, working fine, and the integration with the PoIs was a great addition.

@ArturoManzoli
ArturoManzoli force-pushed the 2602-round-shape-faded-edges branch from 86630a7 to 5be65ed Compare August 7, 2026 17:32
@ArturoManzoli

Copy link
Copy Markdown
Contributor Author

Besides the widget icon thing, the only addition I have is that new map vector layer composables (useMapMissionLayer / useMapVehiclePathLayer / useMapVectorLayer) were created and should also be wired into the main Map widget and Mission Planning view — otherwise we just grew a second copy of that drawing path for this new widget.

Apart from that this new widget looks NEAT, super cool, super useful, working fine, and the integration with the PoIs was a great addition.

Good catch on both.

icon: Made a new circular map icon, based on a crop of the existent Map Widget Icon.

composables: useMapMissionLayer and useMapVehiclePathLayer are wired into src/components/widgets/Map.vue and src/views/MissionPlanningView.vue, so the mission path and the vehicle trail come from one implementation across all three surfaces.

also dropped useMapVectorLayer: once useMapVehiclePathLayer moved to a persistent canvas polyline with incremental appends it was down to a single call site, so it's inlined into useMapMissionLayer.

@ES-Alexander

ES-Alexander commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Looks great in the pictures! Unfortunately I can't test it yet, because of #2911.

I'm somewhat concerned about this being implemented as a new widget, rather than a configuration1 of the existing map widget2. I suspect users setting up a View will be more inclined to think "I want a map; here's how I want it to look", rather than "I want a circular map widget, I wonder if there's another different map widget that lets me have that". That also applies for editing Views, where if a user has an existing map widget (e.g. on top of their video) and finds out that it's possible to have a circular one, they'll likely be confused about how to get it, because they can't just enable that on the map that's there.

It also seems likely that 2 independent map widget implementations will result in unnecessary code duplication (which makes for harder maintenance), but it's possible the composable stuff Raf was talking about helps to avoid that 🤷‍♂️

Footnotes

  1. I am reminded of the ideas in frontend: make output widget definitions multi-use (bases/overlays/mini) #1525, though this is somewhere in between a base and an overlay (in the terminology discussed there).

  2. For additional discoverability, we could open the configuration options as soon as a new map gets placed - just like we open the VGI presets window when adding a new VGI.

@ArturoManzoli

Copy link
Copy Markdown
Contributor Author

Looks great in the pictures! Unfortunately I can't test it yet, because of #2911.

Fix is on its way!

I'm somewhat concerned about this being implemented as a new widget, rather than a configuration1 of the existing map widget2. I suspect users setting up a View will be more inclined to think "I want a map; here's how I want it to look", rather than "I want a circular map widget, I wonder if there's another different map widget that lets me have that". That also applies for editing Views, where if a user has an existing map widget (e.g. on top of their video) and finds out that it's possible to have a circular one, they'll likely be confused about how to get it, because they can't just enable that on the map that's there.

On the separate-widget question, my main concern is the same discoverability one, just pointing the other way. The widget picker is a visual gallery, so a circular vehicle-oriented map shows up there as a picture right next to Map, the same way CompassHUD sits next to Compass. If the circular form is a switch inside the map settings dialog instead, nobody finds it unless they already placed a full-screen map and went digging through its configs. "I want a map, here's how I want it to look" only holds if the user already suspects that look exists. A thumbnail in the picker tells them it does before they place anything, a checkbox two dialogs deep doesn't.

The other half is that this isn't the same widget with a different skin. The map widget is a pannable, interactive surface: layer control, GeoTIFF overlays, tile download for offline use, POI goto and context menu, coordinate grid, home/vehicle arrows, target follower, edit-mission entry. The minimap is deliberately the opposite: dragging: false, no zoom control, no attribution, no on-map buttons, a drag handle that moves the widget instead of panning the map, a circular mask over the tiles, and a bearing tween so the vehicle points up. Folding them together means one boolean silently invalidating most of the map's feature set, and a settings dialog where half the switches only apply depending on another switch. Two obvious widgets with a small config each is easier to reason about than one widget whose valid option combinations you have to guess. Per-type things differ too: default size is 1x1 for the map and 0.18x0.32 for the minimap, and context-menu ownership isn't the same either.

It also seems likely that 2 independent map widget implementations will result in unnecessary code duplication (which makes for harder maintenance), but it's possible the composable stuff Raf was talking about helps to avoid that

Fair, and it's handled. useMapTileLayers, useMapContext, useMapMissionLayer, useMapVehiclePathLayer, useMapPoiMarkers, PoiMapArrows, MapNorthIndicator and vehicleMarkerImageUrl are shared between Map.vue, MissionPlanningView.vue and MiniMap.vue, and after Rafael's note I wired the mission and vehicle-path layers back into the map widget and mission planning, so there's one drawing path across all three surfaces. useMiniMap is 254 lines and only covers what the map widget doesn't have (rotation, radial fade mask, forced recentering). MiniMap.vue is 521 lines against Map.vue's 2084, none of it a second copy of the map.

Provide framework-agnostic helpers to test containment in a circle, clamp
a point onto a circle's boundary, and build the radial-gradient mask used
to fade a map's edges, so the rotating minimap and its edge arrows share
one tested source of circular geometry instead of duplicating the math.
Move the vehicle-type to marker-image mapping out of Map.vue into a shared
resolver so the map widget and the upcoming minimap pick the same icon from
one place, and have Map.vue consume it instead of holding its own imports
and type switch.
@ArturoManzoli
ArturoManzoli force-pushed the 2602-round-shape-faded-edges branch 2 times, most recently from a9a2238 to 0a917ef Compare August 11, 2026 21:24
@ArturoManzoli

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 3

Warning

⚠️ IMPORTANT FIXES REQUIRED — 8 open (1 major, 5 minor, 2 nits), 5 closed.

The PR adds a second map widget: a small round map that stays centred on the vehicle (or on a chosen live-tracked point of interest), can spin so the vehicle always points at the top of the widget, fades out towards its circular edge, and shows off-screen points of interest as pins pinned around that edge. It has no on-map buttons — the map cannot be panned, and a small handle lets the user drag the widget itself around the view. A right-click menu quick-toggles the mission line, the vehicle trail, the north arrow and the points of interest, and a settings dialog picks the map imagery, the fade amount and what the map follows. Along the way the mission line and the vehicle trail are lifted out of the existing map widget and the mission planning screen into two shared pieces of code that all three surfaces now use, which removes the duplicated drawing code that existed in both.

What still needs attention

# Problem What it means Severity Status
4.2 Copyleft map-rotation library inside a commercially-licensed product Cockpit is offered either under a copyleft licence or under a paid custom one; the new rotation library is copyleft and is compiled into the app, so the custom-licence option can no longer legally be offered for the shipped product. major
1.2 Position sources are configurable but their unit conversion is not If anyone points the minimap at a different position source, the vehicle is drawn in the Atlantic instead of where it is. minor
1.3 The rotation library patches the map engine for the whole app Once a minimap has been shown, the big map and the mission planning screen run on a modified map engine for the rest of the session, so a bug in the rotation library can surface on screens that have nothing to do with the minimap. minor
2.2 A setting is stored as "undefined" instead of empty The "which point of interest to follow" setting is written in a form that the vehicle-synced settings system drops, so it cannot reliably reach a second computer or be cleared. minor
5.1 Turning points of interest off hides them but keeps the work running Switching the markers off on the minimap only makes them invisible; the app keeps redrawing and recalculating them behind the scenes, which costs framerate for something the user turned off. minor
8.1 Two silent changes to existing screens ride inside a refactor commit Users who had switched the vehicle trail off on their main map will suddenly find it gone, and the mission line changes colour in mission planning, with nothing in the PR text or the commit subject saying so. minor
11.1 Shared empty list in the widget defaults Nothing misbehaves today, but every widget shares one list object, so the first code that adds to it in place will make one widget's menu entries appear on all of them. nit
11.2 Same map-readiness check now exists twice Two copies of one three-line check will drift apart over time. nit
Since round 2 — no status changed, 0 resolutions applied, comparing 86630a70a917ef

86630a70b12c559e459eb80cd8e4890b38bd98500a917effa2bd814b7e15f1ed9f8f010ce7bdbfde

The incremental diff is unusable this round, so transitions were derived from pr.diff. incremental.diff contains no diff --git headers at all and its contents are base-branch work unrelated to this PR (the Piper text-to-speech engine, .github/claude-review/demo-video-guidelines.md), and 86630a70 does not appear in the commit list in pr.json. The branch was rebased onto current master since round 2, which removed the old head. Every judgement below is against the current pr.diff and the base checkout instead.

previous-ledger.json was [], so the ledger was rebuilt from the five findings written out in round 2's status table. resolutions.json is [] — no /resolve has been issued on this PR, so nothing was closed by a maintainer this round.

No finding changed status this round. All five were already closed at round 2 and are carried through the ledger as they stand: 1.1 (empty JSDoc on the Leaflet global) and 4.1 (window listener cleanup) addressed, both re-confirmed in the current code at src/composables/map/useMiniMap.ts:19-31 and src/components/widgets/MiniMap.vue (onBeforeUnmount removing the mouseup/dragend listeners); 2.1, 10.1 and 10.2 obsolete.

One correction to the record, so the next reader is not misled: round 2 closed 2.1, 10.1 and 10.2 as "no longer applicable" on the strength of the author's explanations, not on a code change. Under these guidelines an explanation cannot close a finding — that outcome should have been disputed. They are nonetheless left closed here rather than reopened, since the ledger records them as closed and two of the three were nits. The dependency question does return this round, in a different and sharper form: 2.1 was about maintenance risk, while 4.2 below is about distribution licensing, which no round has raised before.

Since round 2 the branch was rebased and one commit was added, 0a917eff map: adopt shared mission and vehicle-path layer composables. Finding 8.1 is about what rode along inside it.

Discussion since round 2 — all claims below were checked against the diff rather than taken as given:

  • @ArturoManzoli reported that useMapMissionLayer and useMapVehiclePathLayer are now wired into src/components/widgets/Map.vue and src/views/MissionPlanningView.vue, and that useMapVectorLayer was dropped for having a single call site. Verified: both composables are called in Map.vue and in MissionPlanningView.vue, both files lose their local polyline code (Map.vue −76 lines, MissionPlanningView.vue −70), and no useMapVectorLayer exists anywhere in the diff or the base tree. The duplication half of the earlier review comment is genuinely resolved. What that commit also carried is 8.1.
  • @ES-Alexander raised two concerns: that this should be a configuration of the existing map widget rather than a second widget, and that two map implementations will duplicate code. The second is measurable and is answered: useMapTileLayers, useMapContext, useMapPoiMarkers, useMapMissionLayer, useMapVehiclePathLayer, PoiMapArrows, MapNorthIndicator and vehicleMarkerImageUrl are shared across all three surfaces, and the two existing files shrink. The first is a product decision about discoverability, not something this review can settle; @ArturoManzoli answered it at length here (picker thumbnail beats a buried switch; the two widgets have deliberately opposite interaction models). It remains an open disagreement between two humans and is recorded, not graded. He also notes he has not been able to test the widget yet because of Map is broken #2911, so nothing in this PR has independent hands-on confirmation.
Change map — what was established before judging

Claims (from the PR body; each checked against the code)

  • "Compact, always-centered circular map with faded edges, no on-map controls" — verified. src/composables/map/useMiniMap.ts:1360-1375 creates the map with dragging: false, zoomControl: false, attributionControl: false, rotate: true; applyFadeMask writes a radial-gradient into mask-image on the map container; recenter re-setViews on every position change.
  • "Tracks either the vehicle or a chosen dynamic POI; POI tracking stays north-up" — verified. MiniMap.vue trackedPosition/trackedHeading/effectiveHeadingUp force heading 0 and disable heading-up when trackTarget === 'poi', and the settings switch is :disabled="trackingPoi".
  • "Clickable north indicator animates the map between heading-up and north-up" — verified. toggleHeadingUp flips the option, watched in useMiniMap and tweened by animateBearing over 400 ms with easeInOutQuad, taking the short way around the 360° wrap.
  • "Off-screen POIs show as arrows pinned around the circular edge" — verified. boundary="circle" reaches computeCircularArrow in PoiMapArrows.vue, which clamps to the inscribed circle minus a 24 px inset via clampPointToCircle.
  • "Right-click quick-toggles for mission, vehicle path, north indicator and POIs" — verified. menuItemsuseWidgetContextMenuwidgetManagerVars(hash).contextMenuItems → merged with the Options entry in WidgetHugger.vue:152-158.
  • "Draggable by a handle without entering edit mode" and the offline notice — verified (enableMovingOnDrag toggling allowMovingAndResizing; targetOffline/offlineText).
  • Not claimed anywhere in the PR body: the existing map widget and the mission planning screen are rewired onto the two new composables, and two user-visible behaviours change as a result. See 8.1.

Failure site — the PR is a feature, not a bug fix, so there is no reported failure to locate. It does incidentally fix one: showVehiclePath in Map.vue had a settings switch (Map.vue:132) and a default (Map.vue:578, defaults.ts:383/704/813) but the base vehicle-trail watcher never read it, so the toggle did nothing. useMapVehiclePathLayer is now given show: () => widget.value.options.showVehiclePath && vehicleMarker.value !== undefined, which makes it work. That fix is unannounced — 8.1.

Entry points

Function Reached from Frequency
useMiniMap.recenter (throttled 16 ms) watch(vehiclePosition) ← data lake GLOBAL_POSITION_INT per incoming message
useMiniMap.followBearingsetBearing (throttled 16 ms) watch(vehicleHeading) ← data lake ATTITUDE/yaw per incoming message
useMiniMap.animateBearing watch(headingUp) ← north-indicator click or settings switch per user action, then per frame for 400 ms
useMiniMap.applyFadeMask / applyTileProvider settings slider @end / select watchers per user action
useMiniMap.applyOfflineView watch(vehicleOnline) and init one-shot per connection change
useMiniMap.init / destroy onMounted / onBeforeUnmount one-shot
ResizeObserver callback → invalidateSize + recenter widget resize handles per pointer event while resizing
useMapVehiclePathLayer.redraw watch([revision, show])missionStore.vehiclePositionHistoryRevision, bumped on every vehicle coordinate update per incoming message
useMapMissionLayer.redraw watch([waypoint coords (deep), show]) per user action (mission edit / download / clear)
PoiMapArrows moveHandlerthrottledUpdateArrows (15 ms) Leaflet move and the newly bound rotate, which fires on every setBearing per frame or pointer event
computeCircularArrow / clampPointToCircle / isInsideCircle calculatePoiEdgeArrows per frame or pointer event
buildRadialFadeMask applyFadeMask per user action
useWidgetContextMenu watcher watch(menuItems, deep, immediate) ← the four toggles and hasMission per user action
WidgetHugger.contextMenuItems computed right-click on any widget per user action
vehicleMarkerImageUrl watch(vehicleStore.coordinates) first fix (Map.vue), computed (MiniMap.vue) one-shot / per user action
isLeafletMapReady guard at the top of both new composables' redraw per incoming message

Every changed function has a caller; nothing added by this PR is unreachable, and no new export is groundwork.

Invariants

  • "leaflet-rotate is inert for maps created without rotate: true." The plugin patches L.Map's prototype on the single shared Leaflet module, and globalThis.L = L plus the lazy import('leaflet-rotate') in useMiniMap.ts:19-31 makes that patch permanent for the session as soon as any MiniMap mounts. Sites that can be affected: Map.vue, MissionPlanningView.vue, and every map composable they use (useMapOverlays, useDragMeasureOverlay, useSurveyArrowOverlay, useMapPoiMarkers, useMapTileLayers). The PR covers none of them explicitly — see 1.3.
  • "Removing the local polyline teardown is safe because the composable's show() gate removes the layer instead." Checked at every site: Map.vue.clearMapDrawing sets mapWaypoints.value = [] and vehicleMarker.value = undefined, both of which are watched sources of the two show() getters, so both layers are dropped; MissionPlanningView.clearCurrentMission calls missionStore.clearMission(), which does currentPlanningWaypoints.splice(0) (src/stores/mission.ts:354-358), so show() goes false and the line is removed; removeSelectedWaypoint mutates the same array, so the deep watcher redraws. handleMapMouseMoveNearMissionPath dropped its missionWaypointsPolyline guard and now relies on currentPlanningWaypoints.length < 2, which is equivalent, and it never used the polyline object itself. No dangling reference to missionWaypointsPolyline, vehicleHistoryPolyline, vehicleHistoryRenderer, lastDrawnHistoryLen or getMissionPathLatLngs survives in either file.
  • "One base tile layer per map instance." useMapTileLayers() is a factory called once per composable instance, so the MiniMap's layers are its own and cannot be stolen from the map widget. Verified in the base file.
  • "Every Record<WidgetType, …> covers the new member." isWidgetConfigurable, widgetHasOwnContextMenu and widgetDefaultSizes all gain a MiniMap entry; EditMenu.widgetImages gains the thumbnail; WidgetsView resolves the component by dynamic import from the type name, so no registry entry is missing.
1. Correctness & Implementation Bugs — 2 findings

1.2 — The minimap persists three position-source options but hard-codes the conversion for one of them (minor)

src/components/widgets/MiniMap.vue defaults vehicleLatitudeVariableId, vehicleLongitudeVariableId and vehicleYawVariableId to GLOBAL_POSITION_INT/ATTITUDE templates, resolves them through useResolvedDataLakeTemplate, and then scales the result unconditionally:

// Position comes from the data lake as raw GLOBAL_POSITION_INT (degE7); scale to decimal degrees.
return [rawLatitude.value / 1e7, rawLongitude.value / 1e7]

Reading position from the data lake is exactly right (data-lake-first), and the comment is honest about the assumption. The problem is that the assumption is welded to one source while the source itself is a stored, overridable option: any variable holding decimal degrees — a user-created data-lake variable, or a different message — is divided by ten million and lands within a few metres of 0°N 0°E. There is no UI to change these three ids, so today the only way to hit it is an edited or imported profile, which is precisely how Cockpit profiles get shared.

The tree already has the pattern for this: src/libs/data-sources/altitude.ts pairs every selectable variable id with its own toMeters and passes unknown ids through unchanged (rawAltitudeToMeters). Either mirror that (a small positionSourceOptions table with a toDegrees per entry, plus pass-through for custom ids), or drop the three options and read the fixed paths directly, so the widget stops advertising a configurability it does not have.

1.3 — Mounting a minimap patches the shared Leaflet module for the rest of the session (minor)

src/composables/map/useMiniMap.ts:19-31:

let rotatePluginPromise: Promise<void> | undefined
const ensureRotatePlugin = (): Promise<void> => {
  if (!rotatePluginPromise) {
    ;(globalThis as  ).L = L
    rotatePluginPromise = import('leaflet-rotate').then(() => undefined)
  }
  return rotatePluginPromise
}

The lazy import and the memoised promise are the right shape — the patch is deferred until a minimap actually exists and applied once. But leaflet-rotate works by replacing methods on L.Map's prototype in the one Leaflet module the whole app shares, and there is no way to unpatch it on destroy(). From the moment a user places a MiniMap anywhere in their profile, Map.vue, MissionPlanningView.vue and every overlay composable they use (useMapOverlays, useDragMeasureOverlay, useSurveyArrowOverlay, useWaypointMarkerSize) run on modified containerPoint/layerPoint/getBoundsZoom implementations, which is where those overlays do their pixel maths. The invariant the PR relies on — that the plugin is inert without rotate: true — is the plugin's promise, not something this PR checks.

Two things would close it: state in the PR that the two existing surfaces were exercised with a minimap mounted (pan, zoom, the drag-measure overlay, survey arrows, waypoint drag, the POI edge arrows), since that is the whole exposure; and, if 4.2 leads to replacing the plugin, note that a container-level CSS rotation on the minimap's own map pane would confine the change to this widget instead of the shared module.

2. Persistence & User Data — inventory, 1 finding

Inventory

Key Backend What happened
cockpit-views-group-v1 vehicle-synced (useBlueOsStorage, src/stores/widgetManager.ts) Reshaped: a MiniMap widget entry becomes possible, carrying 13 new option keys (vehicleLatitudeVariableId, vehicleLongitudeVariableId, vehicleYawVariableId, trackTarget, trackedPoiId, tileProvider, edgeFadeAmount, headingUp, showPois, showNorthIndicator, showMission, showVehiclePath, defaultZoom). No existing key is renamed, removed or migrated.
WidgetManagerVars.contextMenuItems (src/types/widgets.ts, src/assets/defaults.ts:53) not persisted_widgetManagerVars is a plain ref({}) in src/stores/widgetManager.ts:83 Added. Confirmed non-persisted, which matters: the registered items carry live function references (action: toggleShowMission), and storing closures in vehicle-synced storage would be a real defect. It is not.
defaults.ts default profiles vehicle-synced on first write Unchanged — no default view gains a MiniMap, so no existing user's layout is touched.

Judged: no machine-specific value is synced, no automatic migration is introduced, nothing is stored under a non-cockpit- key, and no stored value duplicates its own key. The widget follows the Plotter.vue default-merging pattern (onBeforeMount{ ...defaultOptions, ...widget.value.options }), so a MiniMap saved by a future version that adds options will still pick the new ones up. One entry is wrong:

2.2 — trackedPoiId is stored as undefined rather than null (minor)

src/components/widgets/MiniMap.vue, in defaultOptions:

trackedPoiId: undefined as string | undefined,

AGENTS.md:126 is explicit: "Never write undefined into a setting; clear one with null instead. […] null survives JSON.stringify and syncs like any other value, while undefined is dropped from the payload and arrives as a value-less setting, which useBlueOsStorage ignores." This is a vehicle-synced key, so the consequence is the documented one: the "no POI selected" state is not a value that syncs, it is an absent key. It happens to survive here because the onBeforeMount merge re-inserts the default on load, which is why this is minor rather than major — but the same is then true of clearing the selection, which cannot be expressed as a synced value at all. Make it trackedPoiId: null as string | null and widen the trackedPoi/poiSelectItems comparison accordingly; v-select already emits null when cleared, which is why onTrackedPoiSelected is typed string | null.

4. Security — 1 finding

The other sub-checks are clean: no obfuscated code, no encoded blobs (the one binary is src/assets/widgets/MiniMap.png, a widget thumbnail consistent with the eleven already there), no hidden or bidirectional Unicode, no network calls beyond the existing tile providers, no changes to build scripts, postinstall, CI, Docker or the Electron main process, and no new secrets, eval, or v-html. On the new-dependency check: leaflet-rotate is the real Raruto plugin name (no typosquat against leaflet, leaflet.offline or leaflet-edgebuffer), it is placed in correct alphabetical position in package.json, and ^0.2.8 on a 0.x package resolves within 0.2.x only, matching how the two sibling Leaflet plugins are already ranged. What is not clean is the licence.

4.2 — A GPL-3.0 library is linked into the bundle while Cockpit is also offered under a non-copyleft custom licence (major)

The PR adds leaflet-rotate to dependencies and imports it at runtime (useMiniMap.ts), so it is linked into the JavaScript bundle of both the Lite and the Standalone build — the PR's own README table says as much ("Bundled in: Standalone and Lite") and states its licence as GPL-3.0. I have no network access and no node_modules in this checkout, so the licence itself is taken from the PR's assertion rather than verified upstream; the finding rests on that assertion being true.

LICENSE.md offers the work as AGPL-3.0-only OR LicenseRef-Cockpit-Custom — "You can choose between one of them if you use this work." The copyleft arm is fine: GPLv3 §13 explicitly permits combining a GPLv3 work with an AGPLv3 one. The custom arm is not. A GPL-3.0 module compiled into Cockpit's own bundle is part of the combined work, and Blue Robotics cannot sublicense someone else's GPL-3.0 code under a proprietary licence. Anyone who takes the Cockpit Custom option receives GPL-3.0 code under terms that licence does not allow.

What the PR adds does not close this. The README table and the LICENSE.md note are attribution and source-location — necessary, and good that they are here, but they address a different obligation. The gap is the dual offer itself, and the table arguably makes it harder to see by listing three components as one category: FFmpeg and Piper are separate executables invoked as subprocesses in Standalone, which is the "separate program / aggregation" reading Cockpit already relies on, whereas leaflet-rotate is linked into our own module graph. Those are different in kind, so the existing FFmpeg and Piper entries are not precedent for this one.

To satisfy it, one of:

  1. An explicit decision from whoever owns the Cockpit Custom Licence, written into LICENSE.md rather than left in a PR thread — e.g. that the custom arm excludes the copyleft components, which for a linked module means the build must be able to produce a bundle without it (it currently cannot; the MiniMap depends on it).
  2. Obtain a different licence for the plugin from its author.
  3. Avoid the copyleft dependency. The MiniMap needs one thing from it — a bearing on the map — and rotating the minimap's own map pane with a CSS transform, or vendoring a permissively-licensed equivalent, would keep both licence arms intact and would also close 1.3, since the patch would no longer touch the shared Leaflet module.

This is a maintainer call, not a code review one; it is flagged at major because merging as-is ships the conflict in an installer.

5. Performance — 1 finding

Traced first, judged second. The two hot watchers are watch(vehiclePosition) → recenter and watch(vehicleHeading) → setBearing, both fed by data lake variables that update per incoming MAVLink message and both wrapped in useThrottleFn(…, 16), so the map is capped at one reprojection per frame; PoiMapArrows' newly bound rotate handler routes into the existing 15 ms throttles. useMapVehiclePathLayer keeps the base file's incremental addLatLng append (O(1) per revision bump) and its dedicated L.canvas() renderer, so the refactor does not regress the trail's cost, and useMapMissionLayer now updates a persistent polyline in place instead of MissionPlanningView's previous rebuild-and-rebind. Teardown is complete: destroy() cancels the rAF, disconnects the ResizeObserver and calls map.remove(); both layer composables remove their layers onBeforeUnmount; the drag handle's window listeners are removed in three places. No synchronous canvas encoding is added.

5.1 — Hiding the points of interest stops the pixels, not the work (minor)

MiniMap.vue implements its POI toggle in scoped CSS:

/* Hide every PoI representation (in-view markers, tooltips and edge arrows share this toggle). */
.minimap--hide-pois :deep(.minimap-poi-marker-icon),
.minimap--hide-pois :deep(.minimap-poi-tooltip) {
  display: none;
}

With showPois off, useMapPoiMarkers still runs its deep, immediate watcher over resolvedPointsOfInterest, still JSON.stringifys a signature per POI per fire, and still calls setLatLng/setOpacity/setContent on markers nobody can see — for live-tracked POIs that is per incoming message. This PR is the one that establishes the better shape two files over: useMapMissionLayer and useMapVehiclePathLayer both take a show: () => boolean and remove their layer when it is false. useMapPoiMarkers should take the same show?: () => boolean (defaulting to true, so Map.vue and MissionPlanningView.vue are unaffected) and tear its markers down when off, which also removes the need for the CSS rule and its dependence on two class names staying in sync with the composable.

The arrow half is only partly this PR's: calculatePoiEdgeArrows has always computed geometry for every POI regardless of showPoiArrows and left the template to hide it (PoiMapArrows.vue:3), so that waste predates the diff — but binding moveHandler to rotate newly triggers it up to 60 times a second on a heading-up map, hidden or not. An early if (!props.showPoiArrows) { poiEdgeArrows.value = []; return } at the top of calculatePoiEdgeArrows is a one-line fix that benefits the existing map widget too.

8. Commit Hygiene — 1 finding

The eight commits are genuinely atomic and ordered bottom-up (helpers, then the shared marker resolver, then the layer composables, then the hugger menu, then the arrow boundary, then the dependency, then the widget, then the adoption). Scope prefixes match the repository's own style and each one describes its change. No wip/fixup!/address review noise, no commit reverting or reimplementing an earlier one, no PR number in a subject, nothing replicated from a sibling PR. The largest, 6587ebfb widgets: add circular heading-up MiniMap widget, is a single new 533-line component plus its composable; that is one indivisible unit rather than an oversized bundle.

8.1 — The final commit changes two existing surfaces' behaviour without saying so (minor)

0a917eff map: adopt shared mission and vehicle-path layer composables reads as a pure refactor, and mostly is. Two user-visible changes ride inside it, neither mentioned in the commit subject/body nor anywhere in the PR description:

  1. The map widget's "Show vehicle path" switch starts working. The base watcher (Map.vue, removed by this commit) gated the trail on map.value && vehicleMarker.value && newPoints.length, never on widget.options.showVehiclePath, even though the switch exists at Map.vue:132 and defaults to true at Map.vue:578 and in three profiles in defaults.ts. The replacement passes show: () => widget.value.options.showVehiclePath && vehicleMarker.value !== undefined. This is the right fix, but for any user who switched the trail off, saw no effect and left it off, the trail now disappears after upgrading, with nothing telling them why.
  2. The mission line changes colour in mission planning. The old polyline there was created as L.polyline(missionPathLatLngs) with no options, i.e. Leaflet's default #3388ff; the composable defaults to missionPathColor = '#358AC3'. Unifying with the map widget is a sensible call — it is just an undocumented visual change to a screen the PR body never mentions touching.

Per the "behaviour changes ride alone" rule, (1) belongs in its own commit (fix: honor the map widget's show-vehicle-path option) so it can be reverted or backported without the refactor, and it should be stated in the PR body since it changes what existing users see. (2) can stay in the refactor commit, but should be named in its message.

11. Nitpicks / Optional — 2 findings

11.1 — contextMenuItems: [] in defaultWidgetManagerVars is one array shared by every widget (nit)

src/assets/defaults.ts:53 adds contextMenuItems: [], and widgetManagerVars() hands each widget { ...defaultWidgetManagerVars } (src/stores/widgetManager.ts:249-254) — a shallow copy, so every widget that never registers items points at the same array instance. Nothing misbehaves today: useWidgetContextMenu assigns a fresh array rather than mutating, and WidgetHugger reads it with ?? [] and spreads. But this is the first reference-typed value in that defaults object, and the first push into it will show one widget's entries on all of them. Since the field is optional and the only reader already defaults it, the simplest fix is to drop the entry from defaultWidgetManagerVars entirely — which is also the flip side of the optional ? that round 2 discussed at 10.1: keeping the ? makes the default redundant, and keeping the default makes the ? redundant, so one of the two should go.

11.2 — isLeafletMapReady now exists twice (nit)

The PR adds the shared helper at src/libs/map/utils-map.ts and uses it in both new composables, while src/composables/map/useMapPoiMarkers.ts:94-95 keeps a character-for-character identical local isMapReady, including its explanatory comment. That file is not in the diff, so this is pre-existing code and out of the stated scope — but this PR is what made the local copy a duplicate, and converging it is a two-line change in a file the widget already depends on.

Sections with nothing to report (5)

3. AGENTS.md Adherence — ✅ (checked leaflet-rotate's alphabetical position in package.json against leaflet-edgebuffer/leaflet.offline; yarn.lock updated, not package-lock.json; every new export in the six added files carries a non-empty JSDoc with typed @param/@returns, including the globalThis.L property that round 2 raised; the onBeforeMount defaults merge follows the Plotter.vue pattern; the two comments reworded in Map.vue sit on lines whose code the diff changes, so the comment-immutability rule is not breached; the 1e7 conversion in a .vue is the only domain logic left in a component and is raised as 1.2)

6. UI / UX — ✅ (settings dialog anatomy verified: centred title, keyboard-reachable v-btn icon close X at top right, v-divider above the footer, no divider under the header, single variant="text" "Close" on the right for a live-applying dialog; theme="dark" present on both v-selects and the v-radio-group, i.e. every overlay-teleporting control added; no color="primary" or saturated fill anywhere; labels are sentence case; the north indicator is a real <button> with aria-label and title and the drag handle carries both too; glass styling comes from interfaceStore.globalGlassMenuStyles on the card only; container-query clamp() sizing replaces magic pixels; all eight discrete interactions log through the global logUserAction in past tense via @update:model-value/click handlers rather than watch, so BlueOS settings-sync cannot forge log entries; the offline state is announced in words the user can act on, "Vehicle offline" / "No POI selected" / "POI position unavailable", with no protocol jargon)

7. Code Quality & Style — ✅ (no any, no non-null assertions added, optional chaining used throughout including the two props.targetFollower?. call sites that make the prop optional; the geometry is framework-agnostic in src/libs/map/minimap-geometry.ts and the marker resolver in src/libs/vehicle/vehicle-marker.ts, both free of vue imports; formatMetersShort and calculateHaversineDistance are reused rather than re-inlined, replacing two copies of the same distance formatting; the file-growth rule is satisfied in the good direction, Map.vue −76 and MissionPlanningView.vue −70 net; the duplicate readiness helper is 11.2)

9. Tests — ✅ (src/tests/libs/map/minimap-geometry.test.ts is added and covers isInsideCircle, clampPointToCircle including the zero-distance branch, and buildRadialFadeMask's clamping; no existing test is removed, skipped or weakened, and no assertion is loosened anywhere in the diff)

10. Documentation — ✅ (README gains the third-party components table and LICENSE.md a pointer to it; the widget behaves identically in Lite and Standalone — no electronAPI, isElectron() or Electron-only module is touched — so AGENTS.md's Lite/Standalone documentation requirement does not apply; JSDoc coverage on the added modules is complete, including the leaflet-rotate ambient declarations)

Generated by Claude. This is advisory; a human reviewer must still approve.

Draw a mission as a persistent polyline updated in place, so it can carry a
double-click hook and optional per-waypoint dots without being torn down on
every waypoint change, and append the vehicle trail to a Canvas-rendered
polyline so a long history does not stutter. Teardown is owned by the
composables rather than by each consuming view.
@ArturoManzoli
ArturoManzoli force-pushed the 2602-round-shape-faded-edges branch from 0a917ef to 0f8a8f3 Compare August 12, 2026 11:05
@ArturoManzoli

ArturoManzoli commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Done:

  • (1.2) added src/libs/data-sources/position.ts mirroring the altitude.ts pattern, so each coordinate source carries its own converter and anything outside the preset list passes through as decimal degrees instead of being divided by 1e7
  • (2.2) trackedPoiId defaults to null now, not undefined
  • (4.2) keeping the plugin and writing the carve-out into LICENSE.md: the dual offer covers Cockpit's own code, neither arm sublicenses the bundled copyleft components, and the Custom arm grants no rights over them. Also stated there that the current build always includes them, since you correctly noted there is no configuration that omits it. Same position we already take on the FFmpeg and Piper builds, though I take the point that those are subprocesses and this one is linked.
  • (5.1) useMapPoiMarkers takes a show?: () => boolean like the two layer composables and removes its markers when off, so the minimap's POI toggle stops the syncing instead of hiding it in CSS; calculatePoiEdgeArrows also bails early when the arrows are hidden, which helps the map widget too
  • (8.1) split the show-vehicle-path fix into its own commit, named the mission-line colour change in the refactor's message, and added both to the PR body
  • (11.1) dropped contextMenuItems: [] from defaultWidgetManagerVars; the field is optional and the only reader already defaults it
  • (11.2) useMapPoiMarkers uses the shared isLeafletMapReady

Not addressed:

  • (1.3) the patch reaching the other map surfaces is where this is heading rather than a side effect: the plan is to use the rotation plugin for the regular map widget and mission planning as well, so those surfaces running on the patched Leaflet is the intent.

@ArturoManzoli

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

Automated PR Review — round 4

Warning

⚠️ IMPORTANT FIXES REQUIRED — 5 open (1 major, 3 minor, 1 nit), 10 closed.

The PR adds a second map widget: a small round map that stays centred on the vehicle (or on a chosen live-tracked point of interest), can spin so the vehicle always points at the top of the widget, fades out towards its circular edge, and shows off-screen points of interest as pins around that edge. It has no on-map buttons — the map cannot be panned, and a small handle lets the user drag the widget itself around the view. A right-click menu quick-toggles the mission line, the vehicle trail, the north arrow and the points of interest, and a settings dialog picks the map imagery, the fade amount and what the map follows. Along the way the mission line and the vehicle trail are lifted out of the existing map widget and the mission-planning screen into two shared pieces of code that all three surfaces now use, and the existing map widget's "Show vehicle path" switch — which had never done anything — starts working.

What still needs attention

# Problem What it means Severity Status
4.2 Copyleft map-rotation library inside a commercially-licensed product Cockpit is offered either under a copyleft licence or under a paid custom one; the new rotation library is copyleft and is compiled into the app, and the note now added to the licence file says the shipped build always contains it, so the paid option still covers a product that includes someone else's copyleft code. major :large_yellow_circle:
1.2 One of the three configurable position sources still has its unit welded in Latitude and longitude are now converted per source, but the heading source is still assumed to be in radians, so pointing it at a degrees-valued variable spins the map to a wrong direction. minor :large_yellow_circle:
1.3 The rotation library patches the map engine for the whole app Once a minimap has been shown, the big map and the mission-planning screen run on a modified map engine for the rest of the session, so a bug in the rotation library can surface on screens that have nothing to do with the minimap. minor 💬
7.1 The new coordinate-source module copies its matching logic from the altitude one Two copies of the same variable-name parsing will drift, so a fix to one silently leaves the other wrong. minor
11.3 A shared menu prop now means the opposite of what it is documented as Nothing misbehaves, but the next person setting a context menu's width will get a minimum width instead. nit

🙋 Decisions for a human

1.3 — Mounting a minimap patches the shared Leaflet module for the rest of the session
Author's argument: the other map surfaces running on the patched map engine is the intended direction, not a side effect, because the plan is to use the rotation plugin for the regular map widget and mission planning too; he agrees the exposure is worth exercising and will confirm both surfaces with a minimap mounted once the issue blocking his testing is resolved.

  • Accept the argument and leave the code as it is
  • Ask for the change anyway (confine the rotation to the minimap's own container, or gate the plugin load)

The checkbox records the decision; the finding itself closes only on /resolve 1.3 <reason>.

Since round 3 — 5 closed, 1 disputed, 2 partly addressed, 0 resolutions, comparing 0a917ef0f8a8f3

0a917effa2bd814b7e15f1ed9f8f010ce7bdbfde0f8a8f3fe11ebc8aa8189cdecc533ee7b8ea526b

incremental.diff is not usable as a delta this round, so transitions were derived from pr.diff. It lists 22 files, among them src/components/widgets/MiniMap.vue as added, +536/-0 and the README table as +10/-0 — both of which already existed at 0a917eff and were reviewed in round 3. The two earliest commits (b8e5ee9a, b2cb7fc8) are the only PR files it omits, which places the compare's merge base at b2cb7fc8: the branch was rewritten from there upwards, so the compare re-reports almost the whole PR as new. Every judgement below is against the current pr.diff and the base checkout.

resolutions.json is []. No /resolve has been issued on this PR, so nothing was closed by a maintainer this round, and there are no unknown ids to report back.

Closed this round — five, all on code changes located in the diff:

  • 2.2 trackedPoiId stored as undefined — ✅ Addressed. src/components/widgets/MiniMap.vue:214 now reads trackedPoiId: null as string | null, the lookup at :261 compares against it directly, and onTrackedPoiSelected still takes string | null, so clearing the select is now a value that syncs.
  • 5.1 Hiding the points of interest kept the work running — ✅ Addressed, both halves. useMapPoiMarkers gained show?: () => boolean (option declared at src/composables/map/useMapPoiMarkers.ts:31-35) with a removeAllMarkers() early return at the top of syncMarkers, a watcher on the getter that re-syncs when it flips back on, and onBeforeUnmount(removeAllMarkers); the MiniMap passes show: () => widget.value.options.showPois (MiniMap.vue:293). The .minimap--hide-pois CSS rule and its class binding are gone from the diff entirely, so the two class names no longer have to stay in sync with the composable. Map.vue and MissionPlanningView.vue pass no show, and the guard is options.show?.() === false, so they are unaffected. The arrow half landed too: PoiMapArrows.vue:410 bails on !props.showPoiArrows before any geometry, and :623 recomputes through the existing 150 ms debounce when the arrows come back.
  • 8.1 Two unannounced behaviour changes inside a refactor — ✅ Addressed. The trail fix now rides alone as 2ba7469e fix: widgets: map: honor the show-vehicle-path option, whose body states the user-visible consequence ("Users who switched the trail off and saw no effect will find it gone once this lands"); the mission-line colour change is named in 0f8a8f3f's body; and the PR body carries both under "Two existing surfaces change behavior as a result".
  • 11.1 Shared empty list in the widget defaults — ✅ Addressed. src/assets/defaults.ts is no longer in the diff at all, so nothing adds contextMenuItems: [] to defaultWidgetManagerVars. The only reader defaults it (WidgetHugger.vue:152, … .contextMenuItems ?? []), and widgetManagerVars() returns the stored reactive object rather than a copy (src/stores/widgetManager.ts:249-254), so the registration still lands where the reader looks.
  • 11.2 Duplicate map-readiness check — ✅ Addressed. The local isMapReady is deleted from useMapPoiMarkers.ts (with its comment) and all five call sites now use the shared isLeafletMapReady from src/libs/map/utils-map.ts:30-31. The type Map import stays in use at useMapPoiMarkers.ts:65, so nothing is left dangling for the linter.

Still open, with a new grade:

  • 1.2 — :large_yellow_circle: Partially addressed. src/libs/data-sources/position.ts is new and lat/lon now convert per source with pass-through for anything outside the preset list. The finding named three stored position-source options; the third, vehicleYawVariableId, still has its unit welded in at MiniMap.vue:246. Reprinted in full in section 1.
  • 4.2 — :large_yellow_circle: Partially addressed. LICENSE.md gained the carve-out the finding asked for in wording, but the same paragraph states that no build configuration omits the copyleft components, which is the condition the finding attached to that route. Reprinted in full in section 4.
  • 1.3 — 💬 Disputed. The code at useMiniMap.ts:18-30 is unchanged; what changed is the author's account of it. An explanation cannot close a finding, so it stays open and moves to the block at the top of this comment.

Two new findings were raised by re-running the sections over the whole of pr.diff rather than only over what moved: 7.1 on position.ts duplicating altitude.ts's matching machinery, and the nit 11.3 on the shared ContextMenu width prop. That is why the open count drops from 8 to 5 rather than to 3.

Discussion since round 3 — every claim checked against the diff rather than taken as given:

  • @ArturoManzoli listed seven items as done and one as not addressed. Five of the seven are confirmed complete above. Of the other two: the position.ts claim ("each coordinate source carries its own converter and anything outside the preset list passes through") is accurate for the coordinates and the module does mirror altitude.ts closely — closely enough that it copies its regex and suffix helper verbatim, which is 7.1 — but it leaves the yaw source untouched; and the licence claim is accurate as a description of what was written into LICENSE.md, which is not the same as the conflict being resolved. His note that the same position is already taken on FFmpeg and Piper is where the distinction he then concedes matters: those are separate executables, this one is linked into our own bundle.
  • The second comment is the bare /review that triggered this run; nothing to check in it.
  • Nothing in the PR body, the commits or the comments contains text addressed to this review, and no injected instruction was found.
Change map — what was established before judging

Claims (from the PR body; each checked against the code)

  • "Compact, always-centered circular map with faded edges, no on-map controls" — verified. src/composables/map/useMiniMap.ts:185-196 creates the map with dragging: false, zoomControl: false, attributionControl: false, rotate: true; applyFadeMask (:99-105) writes a radial-gradient into mask-image; recenter (:107-111) re-setViews on every position change.
  • "Tracks either the vehicle or a chosen dynamic POI; POI tracking stays north-up" — verified. MiniMap.vue:246-270 forces heading 0 and disables heading-up when trackTarget === 'poi', and the settings switch is :disabled="trackingPoi".
  • "Clickable north indicator animates the map between heading-up and north-up" — verified. toggleHeadingUp flips the option, watched in useMiniMap:238-241 and tweened by animateBearing (:142-166) over 400 ms, taking the short way around the 360° wrap.
  • "Off-screen POIs show as arrows pinned around the circular edge" — verified. boundary="circle" reaches computeCircularArrow (PoiMapArrows.vue:265-285), which clamps to the inscribed circle minus a 24 px inset via clampPointToCircle.
  • "Right-click quick-toggles for mission, vehicle path, north indicator and POIs" — verified. menuItemsuseWidgetContextMenuwidgetManagerVars(hash).contextMenuItems → merged with the Options entry in WidgetHugger.vue:152-158. New this round: ContextMenu.vue:17 sizes the menu to max-content with the passed width as a minimum and :27 stops item labels wrapping, so the longer MiniMap entries fit. Both consumers (WidgetHugger.vue:39 at 200px, Map.vue:107 at 260px) still render at their old width unless content exceeds it, and positionStyles (ContextMenu.vue:89-106) right-anchors past 75% of the viewport, so the widest added label cannot push a menu off-screen. Graded only as the nit 11.3.
  • "Position comes from overridable data-lake sources, each scaled by its own converter" — partly verified. True for latitude and longitude (MiniMap.vue:242-243 through rawCoordinateToDegrees), not for the yaw source (:246, degrees(rawYaw.value)). See 1.2.
  • "The POI toggle removes the markers rather than hiding them" — verified, see 5.1 in the block above.
  • "Two existing surfaces change behavior as a result" — verified, and both are now stated in the PR body and in commit messages.

Failure site — the PR is a feature, so there is no reported failure to locate. It fixes one incidentally, and that fix now has its own commit: showVehiclePath had a switch (Map.vue:132) and a default (Map.vue:578, three profiles in defaults.ts) that the base trail watcher never read. useMapVehiclePathLayer is given show: () => widget.value.options.showVehiclePath && vehicleMarker.value !== undefined (Map.vue:470 in the diff), and because the watcher tracks the getter, flipping the switch now takes effect immediately instead of on the next position update.

Entry points

Function Reached from Frequency
useMiniMap.recenter (throttled 16 ms) watch(vehiclePosition) ← data lake GLOBAL_POSITION_INT per incoming message
useMiniMap.followBearingsetBearing (throttled 16 ms) watch(vehicleHeading) ← data lake ATTITUDE/yaw per incoming message
rawCoordinateToDegrees the vehiclePosition computed in MiniMap.vue:240-244 per incoming message
useMiniMap.animateBearing watch(headingUp) ← north-indicator click or settings switch per user action, then per frame for 400 ms
useMiniMap.applyFadeMask / applyTileProvider settings slider @end / select watchers per user action
useMiniMap.applyOfflineView watch(vehicleOnline) and init one-shot per connection change
useMiniMap.init / destroy onMounted / onBeforeUnmount one-shot
ResizeObserver callback → invalidateSize + recenter widget resize handles per pointer event while resizing
useMapVehiclePathLayer.redraw watch([revision, show])vehiclePositionHistoryRevision per incoming message
useMapMissionLayer.redraw watch([waypoint coords (deep), show]) per user action (mission edit / download / clear)
useMapPoiMarkers.syncMarkers watch(resolvedPointsOfInterest, deep); new watch(options.show) per incoming message (live-tracked POIs) / per user action
useMapPoiMarkers.removeAllMarkers the show guard in syncMarkers, and onBeforeUnmount per user action / one-shot
PoiMapArrows moveHandlerthrottledUpdateArrows (15 ms) Leaflet move and the newly bound rotate, which fires on every setBearing per frame or pointer event
PoiMapArrows watch(showPoiArrows)debouncedUpdateArrows (150 ms) the POI toggle per user action
computeCircularArrow / clampPointToCircle / isInsideCircle calculatePoiEdgeArrows per frame or pointer event
buildRadialFadeMask applyFadeMask per user action
useWidgetContextMenu watcher watch(menuItems, deep, immediate) ← the four toggles and hasMission per user action
WidgetHugger.contextMenuItems computed → ContextMenu width style right-click on any widget per user action
vehicleMarkerImageUrl watch(vehicleStore.coordinates) first fix (Map.vue), computed (MiniMap.vue) one-shot / per user action
isLeafletMapReady guard in both new layer composables and now in all five useMapPoiMarkers sites per incoming message

Every changed function has a caller; nothing added is unreachable. One export has no call site outside its own module (positionSourceOptions), folded into 7.1 rather than raised separately.

Invariants

  • "leaflet-rotate is inert for maps created without rotate: true." Unchanged from round 3: the plugin patches L.Map's prototype on the single shared Leaflet module, and globalThis.L = L plus the lazy import('leaflet-rotate') (useMiniMap.ts:18-30) makes that permanent for the session once any MiniMap mounts. Sites that can be affected: Map.vue, MissionPlanningView.vue, useMapOverlays, useDragMeasureOverlay, useSurveyArrowOverlay, useWaypointMarkerSize, useMapPoiMarkers. The PR still covers none of them explicitly — 1.3, now disputed.
  • "Sources outside the preset list are already in decimal degrees." Held by rawCoordinateToDegrees for the two coordinate ids: POSITION_PATH_PATTERN matches both the templated and the resolved concrete path, so resolvedLatitudeId.value (/mavlink/1/1/GLOBAL_POSITION_INT/lat) resolves to the same suffix as the stored template and picks up degE7ToDegrees; a non-matching id passes through unscaled. The one producer that can still break the "each source carries its own converter" rule is the yaw id — 1.2.
  • "show: false means the layer/markers are gone, not hidden." Checked at every consumer: useMapVehiclePathLayer.redraw and useMapMissionLayer.redraw call their clear/removeLine on a false show and reset lastDrawnLength, so a hide/show cycle rebuilds from scratch instead of appending onto a stale count; useMapPoiMarkers.syncMarkers removes every marker and empties the record. Map.vue.clearMapDrawing no longer sweeps polylines by colour (if (l instanceof L.Marker)), and does not need to: it empties mapWaypoints and clears vehicleMarker, both of which are watched sources of the two show() getters. MissionPlanningView.clearCurrentMission reaches the same result through missionStore.clearMission(). No dangling reference to missionWaypointsPolyline, vehicleHistoryPolyline, vehicleHistoryRenderer or lastDrawnHistoryLen survives in either file. The one thing removeAllMarkers does not reset is gotoTargetId, which is harmless because the only surface passing show (the MiniMap) never reads it.
  • "Every Record<WidgetType, …> covers the new member." isWidgetConfigurable, widgetHasOwnContextMenu and widgetDefaultSizes all gain a MiniMap entry; EditMenu.widgetImages gains the thumbnail; WidgetsView resolves the component by dynamic import from the type name.
1. Correctness & Implementation Bugs — 2 findings (both carried from round 3)

1.2 — The heading source is still a stored option with its unit welded in (minor, carried from round 3, partially addressed)

What the finding asked for: mirror the altitude.ts pattern for the three stored position-source options, or drop the options. Two of the three landed. src/libs/data-sources/position.ts now pairs each coordinate preset with its own toDegrees and passes unknown ids through unchanged, and MiniMap.vue:242-243 routes both coordinates through it:

return [
  rawCoordinateToDegrees(resolvedLatitudeId.value, rawLatitude.value),
  rawCoordinateToDegrees(resolvedLongitudeId.value, rawLongitude.value),
]

The third is untouched. MiniMap.vue:212 persists vehicleYawVariableId: '/mavlink/{{autopilotSystemId}}/1/ATTITUDE/yaw', resolves it at :231, and then converts it on the assumption of the one source it happens to default to (:246):

const vehicleHeading = computed(() => (typeof rawYaw.value === 'number' ? degrees(rawYaw.value) : 0))

ATTITUDE/yaw is radians, so degrees() is right for the default and wrong for anything else the option can hold — a heading already in degrees comes out multiplied by 57.3, which the map applies as a bearing, and VFR_HUD/heading (degrees, and the obvious alternative a user would reach for) is exactly that case. Reachability is the same as the coordinates': there is no UI for these three ids, so it takes an edited or imported profile, which is how Cockpit profiles get shared. The fix is the same shape as the one already written — a headingSourceOptions entry with a toDegrees per source and pass-through for the rest, or drop vehicleYawVariableId from the persisted options and read the fixed path. Whichever way, all three of the stored ids should end up under one rule; leaving two converted and one welded is the harder state to reason about.

1.3 — Mounting a minimap patches the shared Leaflet module for the rest of the session (minor, carried from round 3, disputed)

src/composables/map/useMiniMap.ts:18-30, unchanged this round:

let rotatePluginPromise: Promise<void> | undefined
const ensureRotatePlugin = (): Promise<void> => {
  if (!rotatePluginPromise) {
    ;(globalThis as  ).L = L
    rotatePluginPromise = import('leaflet-rotate').then(() => undefined)
  }
  return rotatePluginPromise
}

The lazy import and the memoised promise are the right shape — the patch is deferred until a minimap exists and applied once. But leaflet-rotate replaces methods on L.Map's prototype in the one Leaflet module the whole app shares, and there is no way to unpatch it on destroy(). From the moment a user places a MiniMap anywhere in their profile, Map.vue, MissionPlanningView.vue and every overlay composable they use (useMapOverlays, useDragMeasureOverlay, useSurveyArrowOverlay, useWaypointMarkerSize) run on modified containerPoint/layerPoint/getBoundsZoom implementations, which is where those overlays do their pixel maths. The invariant the PR relies on — that the plugin is inert without rotate: true — is the plugin's promise, not something this PR checks.

The author's position is that this is the intended direction rather than a side effect, since the plan is to rotate the regular map widget and mission planning too. That is a coherent plan, and it does not change what merging this commit does: it makes the patch reach two surfaces that are not rotated yet and were not exercised with it. What would close it in code is confining the rotation to the minimap's own container (a CSS transform on its map pane), which would also close 4.2's third option; what would close it without code is the confirmation the author offers — the two existing surfaces exercised with a minimap mounted (pan, zoom, drag-measure, survey arrows, waypoint drag, POI edge arrows) — stated in the PR. Until one of those happens it stays open, and the choice is in the Decisions block above.

2. Persistence & User Data — inventory, no open findings

Inventory

Key Backend What happened
cockpit-views-group-v1 vehicle-synced (useBlueOsStorage, src/stores/widgetManager.ts) Reshaped: a MiniMap widget entry becomes possible, carrying 13 new option keys (vehicleLatitudeVariableId, vehicleLongitudeVariableId, vehicleYawVariableId, trackTarget, trackedPoiId, tileProvider, edgeFadeAmount, headingUp, showPois, showNorthIndicator, showMission, showVehiclePath, defaultZoom). No existing key is renamed, removed or migrated.
WidgetManagerVars.contextMenuItems (src/types/widgets.ts:637-641) not persisted_widgetManagerVars is a plain ref({}) (src/stores/widgetManager.ts:83) Added, and no longer seeded in defaultWidgetManagerVars (11.1). Confirmed non-persisted, which matters: the registered items carry live function references (action: toggleShowMission), and storing closures in vehicle-synced storage would be a real defect.
defaults.ts default profiles vehicle-synced on first write Unchanged, and no longer in the diff at all — no default view gains a MiniMap, so no existing user's layout is touched.

Judged: no machine-specific value is synced, no automatic migration is introduced, nothing is stored under a non-cockpit- key, and no stored value duplicates its own key. The widget follows the Plotter.vue default-merging pattern (onBeforeMount{ ...defaultOptions, ...widget.value.options }), so a MiniMap saved by a future version that adds options still picks the new ones up. The one wrong entry from round 3 is fixed: trackedPoiId is null as string | null (MiniMap.vue:214), so "no POI selected" is now a value that syncs rather than an absent key, per AGENTS.md:126.

One behaviour-change entry worth keeping visible even though it is not a persistence defect: showVehiclePath already exists in three stored profiles at true, and this PR makes the stored value take effect for the first time. Users who set it to false are carried over correctly — they get what they asked for — and the PR body now tells them why the trail vanished. That is the "explicit, stated decision" the section asks for.

4. Security — 1 finding (carried from round 3)

The other sub-checks are unchanged and clean: no obfuscated code, no encoded blobs (the one binary is src/assets/widgets/MiniMap.png, a widget thumbnail consistent with the eleven already there), no hidden or bidirectional Unicode, no network calls beyond the existing tile providers, no changes to build scripts, postinstall, CI, Docker or the Electron main process, and no new secrets, eval or v-html. leaflet-rotate is the real Raruto plugin name (no typosquat against leaflet, leaflet.offline or leaflet-edgebuffer), sits in correct alphabetical position in package.json:74, and ^0.2.8 on a 0.x package resolves within 0.2.x only, matching the two sibling Leaflet plugins.

4.2 — A GPL-3.0 library is linked into the bundle while Cockpit is also offered under a non-copyleft custom licence (major, carried from round 3, partially addressed)

What landed this round is the wording the finding asked for. LICENSE.md now says the dual offer covers Cockpit's own source code, that bundled third-party components stay under their own licences and cannot be sublicensed by either arm, and specifically:

In particular, the Cockpit Custom License grants no rights over the bundled copyleft components. A recipient choosing that arm receives Cockpit's own code under it, and each copyleft component under that component's own license.

That is a real improvement in transparency and it is the right first half. The same paragraph then states the condition the finding attached to that route, and states it as unmet: there is no build configuration that omits them. For FFmpeg and Piper that is survivable under the reading Cockpit already relies on — they are separate executables invoked as subprocesses. leaflet-rotate is different in kind: it is imported at runtime (useMiniMap.ts:29) and linked into the JavaScript bundle of both the Lite and the Standalone build, so the artifact a recipient receives under the Cockpit Custom arm is one combined work containing GPL-3.0 code. Disclaiming rights over the component does not change what is being distributed, because the terms at issue attach to the combined work, not only to the file. A licence that grants no rights over a part the recipient cannot separate is not a licence they can act on.

As before, I have no network access and no node_modules here, so the GPL-3.0 licence itself is taken from the PR's own assertion (README table, 733544ec's commit body) rather than verified upstream; the finding rests on that assertion being true.

To satisfy it, one of:

  1. Make the carve-out true in the build: a configuration that produces a bundle without the copyleft components, which for this one means the MiniMap degrades to a non-rotating map rather than failing to build. LICENSE.md currently documents the opposite.
  2. Obtain a different licence for the plugin from its author.
  3. Avoid the copyleft dependency. The MiniMap needs one thing from it — a bearing on the map — and rotating the minimap's own map pane with a CSS transform, or vendoring a permissively-licensed equivalent, keeps both licence arms intact and also closes 1.3.
  4. A decision from whoever owns the Cockpit Custom Licence that the offer as now worded is acceptable for a linked component. That is not a code-review call, and it is the one route that closes this without touching code — /resolve 4.2 <reason> is how it gets recorded.

Still major: merging as-is ships the combination in an installer.

7. Code Quality & Style — 1 finding

Everything else in this section is unchanged from round 3 and still holds: no any, no added non-null assertions, optional chaining at the two props.targetFollower?. sites that make the prop optional, the geometry and the marker resolver framework-agnostic under src/libs/, formatMetersShort and calculateHaversineDistance reused rather than re-inlined, and net deletions in both large files (Map.vue −41, MissionPlanningView.vue −56).

7.1 — position.ts copies altitude.ts's path-matching machinery instead of sharing it (minor)

src/libs/data-sources/position.ts is the right module in the right place, and mirroring altitude.ts was the ask. What it mirrors, though, includes the parsing, character for character. position.ts:26-28:

// Captures the suffix from both templated and concrete preset paths.
const POSITION_PATH_PATTERN = /^\/mavlink\/(?:\d+|\{\{autopilotSystemId\}\})\/\d+\/(.+)$/
const extractPositionSuffix = (variableId: string): string | undefined => variableId.match(POSITION_PATH_PATTERN)?.[1]

src/libs/data-sources/altitude.ts:41-50 has the identical regex under a different name, the identical one-line extractor, and the same "find the option whose suffix matches" lookup that rawCoordinateToDegrees (position.ts:38-42) repeats inline. AGENTS.md:165 is direct about this: "If the same logic would live in two or more places, extract it once and reuse it." The two copies are the same rule about how a MAVLink data-lake variable id is shaped, so the next change to that shape — a component id that is not numeric, a second templated segment — has to be found twice, and the copy that gets missed fails silently by falling through to pass-through.

One shared helper in src/libs/data-sources/ covers both: findSourceOptionForVariableId(options, variableId) over a { value: string }[], with each module keeping only its own table and converter names. That also removes the reason to export positionSourceOptions and PositionSourceOption at all — nothing outside the module consumes either (unlike altitudeSourceOptions, which feeds widget config menus), so both can stay local once the lookup lives elsewhere, and the module's public surface shrinks to the three things MiniMap.vue actually imports.

11. Nitpicks / Optional — 1 finding

11.3 — ContextMenu's width prop is now a minimum width, and still documented as the width (nit)

src/components/ContextMenu.vue:17 changed from { width: width } to { minWidth: width, width: 'max-content' }, which is the right call for the MiniMap's longer entries and leaves both existing consumers rendering at their old size. The prop's contract changed with it, and its documentation did not (:68-71):

/**
 * The width of the context menu.
 */
width?: string

Two call sites pass a value (WidgetHugger.vue:39 200px, Map.vue:107 260px) and neither now gets what the name and the comment promise. Renaming the prop to minWidth — or, if the churn is not worth it, saying "minimum width; the menu grows to fit its content" — costs one line and stops the next author debugging why their exact width was ignored. This is the one place where the comment-immutability rule does not apply in the author's favour: the comment is stale because of this diff, so updating it is part of the change rather than unrelated churn.

Sections with nothing to report (6)

3. AGENTS.md Adherence — ✅ (leaflet-rotate's alphabetical position re-checked against leaflet-edgebuffer/leaflet.offline in package.json:73-75; yarn.lock updated, not package-lock.json; every new export in the seven added files carries a non-empty JSDoc with typed @param/@returns, position.ts included; the onBeforeMount defaults merge follows the Plotter.vue pattern; the two comments reworded in Map.vue sit on lines whose code the diff changes; the domain logic that round 3 flagged as living in a .vue has moved out into src/libs/data-sources/position.ts, leaving only the yaw conversion raised as 1.2)

5. Performance — ✅ (re-traced after the POI change: syncMarkers now returns after removeAllMarkers() when hidden, so the per-message path for live-tracked POIs costs one getter call and two empty iterations instead of a JSON.stringify signature per POI plus setLatLng/setOpacity/setContent; the re-enable watcher routes through the existing 150 ms debounce in PoiMapArrows and a single full syncMarkers in the composable, so nothing double-computes; the two hot minimap watchers remain useThrottleFn(…, 16); useMapVehiclePathLayer keeps its L.canvas() renderer and O(1) append, and resets lastDrawnLength on hide so a hide/show cycle rebuilds instead of appending onto a stale index; teardown re-verified — rAF cancelled, ResizeObserver disconnected, map.remove(), both layer composables and now useMapPoiMarkers removing on onBeforeUnmount)

6. UI / UX — ✅ (settings dialog anatomy re-checked: centred title, keyboard-reachable v-btn icon close X at top right with equal insets, v-divider above the footer, no divider under the header, single variant="text" "Close" on the right for a live-applying dialog; theme="dark" on both v-selects and the v-radio-group; no color="primary" or saturated fill; sentence-case labels; the north indicator and drag handle are real buttons with aria-label and title; all eight discrete interactions log through logUserAction in past tense from handlers rather than watchers; the new ContextMenu sizing keeps the glass layer and the 200/260px minimums, and cannot push a menu past the viewport edge given the existing right-anchoring past 75%; offline copy stays jargon-free)

8. Commit Hygiene — ✅ (re-read the nine commits in pr.json: the show-vehicle-path fix now rides alone in 2ba7469e with its user-visible effect named in the body, the mission-line colour is named in 0f8a8f3f's body, subjects stay scope-prefixed in the repository's style, and there is no wip/fixup! noise; 2ba7469e's gate is rewritten two commits later by the composable adoption, which is the price of the split that was asked for and is what keeps that commit cherry-pickable onto a release branch, so it is not graded as a self-correction)

9. Tests — ✅ (src/tests/libs/map/minimap-geometry.test.ts still covers isInsideCircle, clampPointToCircle including the zero-distance branch, and buildRadialFadeMask's clamping; no existing test is removed, skipped or weakened, and no assertion is loosened anywhere in the diff)

10. Documentation — ✅ (README's third-party components table lists all three GPL-3.0 components with their upstream projects and which distributions carry them, and LICENSE.md now spells out how the dual offer relates to them — whether that wording is sufficient is 4.2, not a documentation gap; the widget behaves identically in Lite and Standalone, with no electronAPI, isElectron() or Electron-only module touched, so AGENTS.md's parity note does not apply; JSDoc coverage on the added modules is complete, leaflet-rotate's ambient declarations included)

Generated by Claude. This is advisory; a human reviewer must still approve.

Track per-widget context-menu items on the widget manager and merge them
into the WidgetHugger menu, with a composable that keeps a widget's items
in sync and clears them on unmount, so a widget can offer quick toggles
without owning its own menu. Size the menu to its content so the added
entries do not wrap, which turns the width prop into a minimum and renames
it accordingly at all three call sites.
Let the edge-arrow overlay clamp points to a circular boundary and
recompute on map rotation, not just on pan, so a round heading-up map
keeps its off-screen POI arrows pinned to the visible edge. Reuse the
shared distance formatter for the arrow labels instead of repeating the
threshold inline.
Add the leaflet-rotate plugin so a Leaflet map can be rotated by bearing,
which the heading-up minimap relies on to keep the vehicle pointing up. The
plugin is GPL-3.0 and ships inside both distributions, so the README now
carries a third-party component list naming it alongside the FFmpeg and
Piper builds already bundled under the same license, and LICENSE.md states
that neither arm of the dual offer sublicenses those components.
Move the path-suffix matching that resolves a data lake variable ID to its
preset out of the altitude module and into a findSourceOptionForVariableId
helper over any table of sources, so the rule for how a MAVLink variable ID
is shaped lives once instead of being copied by each quantity that needs it.
Add a compact always-vehicle-centered minimap that rotates so the vehicle
points up, fades to a transparent circular edge, and pins off-screen POIs
as arrows around that edge, giving a game-style situational view with no
on-map controls. A clickable north indicator animates between heading-up
and north-up, a drag handle moves the widget, and mission, vehicle-path,
POI and north-indicator visibility toggle from the context menu; the
canvas dims and shows a "vehicle offline" notice when no vehicle is
connected. Position comes from overridable data-lake sources, each scaled
by its own converter so a source already in decimal degrees is not scaled
as if it were degE7, and the POI toggle removes the markers rather than
hiding them, so switching them off also stops their syncing.
The map widget's "Show vehicle path" switch has never done anything: the
trail watcher gated on the map, the vehicle marker and the point count,
but never on the option behind the switch, so the trail was always drawn.
Gate both the watcher and the mount-time draw on it, and watch the option
so toggling it takes effect immediately instead of on the next position
update. Users who switched the trail off and saw no effect will find it
gone once this lands.
Draw the mission path and the vehicle trail in both the map widget and the
mission-planning view through useMapMissionLayer and useMapVehiclePathLayer,
collapsing the per-view polyline logic the MiniMap widget had duplicated into
a single shared implementation. The planning view's mission path changes
colour as a result, adopting the shared mission-path blue over Leaflet's
default, so both surfaces draw the same line.
@ArturoManzoli
ArturoManzoli force-pushed the 2602-round-shape-faded-edges branch from 0f8a8f3 to 91ad319 Compare August 12, 2026 12:05
@ArturoManzoli

Copy link
Copy Markdown
Contributor Author

Done:

  • (1.2) the heading source now goes through a heading.ts table the same way the coordinates do, so ATTITUDE/yaw gets its radian conversion and GLOBAL_POSITION_INT/hdg its centidegrees, while anything else is taken as already in degrees. All three stored ids are under one rule now.
  • (7.1) pulled the path-suffix matching out of altitude.ts into findSourceOptionForVariableId, so the rule for how a MAVLink variable id is shaped lives once. Landed as its own commit before the widget, and position.ts no longer exports its table or type since nothing outside it consumes either.
  • (11.3) renamed the ContextMenu prop to minWidth and updated the call sites. There were three, not two: MiniWidgetInstantiator.vue:9 also passes 200px and would have quietly lost it.

Not addressed:

  • (1.3) resolving separately, see below.

@ArturoManzoli

Copy link
Copy Markdown
Contributor Author

/resolve 4.2 Keeping leaflet-rotate on the same footing as the FFmpeg and Piper builds we already ship. LICENSE.md now states that the dual offer covers Cockpit's own code, that neither arm sublicenses the bundled copyleft components, and that the Custom arm grants no rights over them, with the README naming each component and where its source comes from. The point that a linked module differs in kind from a subprocess is taken, and so is the point that the wording is a decision rather than a build change. That decision is mine to make and I am making it here rather than gating the widget on a no-plugin build configuration.

@ArturoManzoli

ArturoManzoli commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

/resolve 1.3 Patching the shared Leaflet module is the intended direction rather than a side effect: the rotation plugin is planned for the regular map widget and mission planning too, so those surfaces running on the patched module is the point.

@github-actions

Copy link
Copy Markdown

Recorded: ArturoManzoli resolved 4.2. Comment /review to apply it.

@github-actions

Copy link
Copy Markdown

Recorded: ArturoManzoli resolved 1.3. Comment /review to apply it.

@ArturoManzoli

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

Copy link
Copy Markdown

Automated PR Review — round 5

Tip

✅ READY TO MERGE — 0 open, 15 closed (3 fixed this round, 2 settled by /resolve).

The PR adds a circular, always-vehicle-centered map widget that rotates so the vehicle's heading points up, fades to a transparent edge, and pins off-screen points of interest as arrows around that edge, with a right-click menu for quick visibility toggles and a settings dialog for the tile provider, the tracking target and the fade amount. Along the way it extracts the mission line and the vehicle trail into two shared layer composables that the existing map widget and the mission-planning view now use as well, extracts the vehicle-marker picker and the MAVLink variable-id lookup into shared modules, and fixes the map widget's "Show vehicle path" switch, which was never read. It also adds a new runtime dependency, leaflet-rotate, which is GPL-3.0 and ships inside both distributions.

What still needs attention

Nothing is open. Every finding raised over the five rounds is closed: twelve by code changes, one retracted as never valid, two settled by a maintainer with /resolve.

Since round 4 — 5 closed (3 fixed, 2 resolved), comparing 0f8a8f391ad319

Range: 0f8a8f3fe11ebc8aa8189cdecc533ee7b8ea526b91ad319fdf72e116168f7c6805edb3179eb04a26.

incremental.diff is not usable as a delta this round, again. It lists 23 files, including src/components/widgets/MiniMap.vue as added and LICENSE.md / README.md as modified — all of which were already present at 0f8a8f3 and reviewed in round 4. The files it omits are exactly those introduced by b8e5ee9a, b2cb7fc8 and dd5f55cb (minimap-geometry.ts, vehicle-marker.ts, the geometry test, useMapMissionLayer.ts, useMapVehiclePathLayer.ts), which places the compare's merge base at dd5f55cb rather than at the previous head: the branch was rewritten from 3848930 upwards (those commits now carry commit dates of 12 Aug 11:34). Status transitions below are therefore judged against pr.diff and the base checkout, not against the increment. Comparing the current file list with round 4's, the genuinely new content is src/libs/data-sources/heading.ts, src/libs/data-sources/source-options.ts, the altitude.ts rewiring, the ContextMenu prop rename spreading to three call sites, and one new commit, 8848be93 ("libs: data-sources: share the mavlink variable-id lookup").

Resolutions applied (both by ArturoManzoli, who the workflow confirmed has write access):

  • ☑️ 4.2 — GPL-3.0 leaflet-rotate linked into the bundle conflicts with the Cockpit Custom licence arm — Resolved. Reason, quoted: "Keeping leaflet-rotate on the same footing as the FFmpeg and Piper builds we already ship. LICENSE.md now states that the dual offer covers Cockpit's own code, that neither arm sublicenses the bundled copyleft components, and that the Custom arm grants no rights over them, with the README naming each component and where its source comes from. The point that a linked module differs in kind from a subprocess is taken, and so is the point that the wording is a decision rather than a build change. That decision is mine to make and I am making it here rather than gating the widget on a no-plugin build configuration." The licence text it refers to is in the diff (LICENSE.md:4-13, README.md:341-348, package.json:74); the decision itself is the maintainer's, and is recorded rather than re-litigated.
  • ☑️ 1.3 — Mounting a MiniMap patches the shared Leaflet module for the whole session — Resolved. Reason, quoted: "Patching the shared Leaflet module is the intended direction rather than a side effect: the rotation plugin is planned for the regular map widget and mission planning too, so those surfaces running on the patched module is the point. The exposure is still worth exercising, and I will confirm both with a minimap mounted once Map is broken #2911 unblocks testing." (The recorded reason runs a sentence longer than the comment it came from; both carry the same decision and the same author.) The code is unchanged: useMiniMap.ts:19-30 still assigns globalThis.L before the lazy import('leaflet-rotate').

No id in resolutions.json was unknown to the ledger; both matched an open finding, and neither had been applied in an earlier round.

Closed by code this round:

  • 1.2 — MiniMap persists three position-source options but the yaw source still hard-codes its conversion — Addressed. The finding asked for the stored yaw id to decide its own conversion the way latitude and longitude do, so that a user who repoints it at GLOBAL_POSITION_INT/hdg does not get a radians-to-degrees conversion applied to centidegrees. src/libs/data-sources/heading.ts:12-15 now holds a two-entry table (ATTITUDE/yawdegrees, GLOBAL_POSITION_INT/hdg/100) with a pass-through for anything else, and MiniMap.vue:246-249 routes the raw value through rawHeadingToDegrees(resolvedYawId, rawYaw). The default comes from defaultHeadingVariableId (MiniMap.vue:212), so all three stored ids are under one rule and no degrees() call is left at the call site.
  • 7.1 — position.ts duplicates altitude.ts's path-suffix matching instead of sharing it — Addressed. The finding asked for the suffix-matching rule to live once, and for position.ts to stop exporting a table and a type that nothing consumed. Both parts landed: src/libs/data-sources/source-options.ts:19-26 holds the single generic findSourceOptionForVariableId; altitude.ts:49-50,60-61 now calls it, with the local ALTITUDE_PATH_PATTERN, extractAltitudeSuffix and findOptionForVariableId deleted and no other caller of them left in the base file; heading.ts:28 and position.ts:34 use the same helper; and position.ts's table and its PositionSourceOption type are no longer exported. It arrived as its own commit ahead of the widget, 8848be93.
  • 11.3 — ContextMenu's width prop became a minimum width but is still documented as the width — Addressed. The prop is now minWidth, with the doc comment rewritten to say the menu grows past it to fit its content (src/components/ContextMenu.vue:17,69-71,108), and all three consumers were updated: WidgetHugger.vue:39 and MiniWidgetInstantiator.vue:9 (min-width="200px"), and Map.vue:107 (:min-width="'260px'"). A grep for other importers of @/components/ContextMenu.vue finds none, so no call site still passes the old name.

Discussion since round 4: the author's summary comment (comment) describes exactly the three fixes above; each was verified against the diff rather than taken on its word. One correction is owed in the other direction: round 4's 11.3 text said the prop had two call sites. It had three — MiniWidgetInstantiator.vue:9 also passes 200px, and had the rename gone only to the two sites I named, that menu would have silently lost its width. The finding was right; my enumeration under it was not.

Nothing in the PR body, the diff or the comments contained text addressed to the reviewer, and no injected instruction was found.

New findings this round: none. Sections 0-11 were re-run over the whole of pr.diff, not over the increment. Beyond the three fixes, that pass re-checked: that no reference to the removed missionWaypointsPolyline / vehicleHistoryPolyline / lastDrawnHistoryLen / vehicleHistoryRenderer survives (26 occurrences in the base MissionPlanningView.vue and 24 in the base Map.vue, every one inside a removed hunk); that logUserAction needs no import because src/libs/cosmos.ts:666 installs it on the global object; that widgetManagerVars returns the stored reactive object rather than a copy (src/stores/widgetManager.ts:249-254), so the context-menu registration actually persists; that WidgetsView.vue:76 resolves MiniMap.vue by convention from the enum name, so the new WidgetType entry is enough to render it; and that the new lockfile entry for leaflet-rotate@0.2.8 resolves to the public registry with an integrity hash.

Change map — what was established before judging

Claims (from the PR body and, for the last three, the author's round-4 reply — all treated as hypotheses and checked against the code):

Claim Verdict
A compact circular map with faded edges and no on-map controls Verified — useMiniMap.ts:185-196 builds the map with dragging, zoomControl, attributionControl, rotateControl, touchRotate and shiftKeyRotate all off; the circle and the fade come from buildRadialFadeMask applied as a CSS mask on the container (useMiniMap.ts:99-105, minimap-geometry.ts:67-71)
Tracks the vehicle or a chosen dynamic POI; POI tracking stays north-up Verified — MiniMap.vue:262-263: trackedHeading returns 0 and effectiveHeadingUp is false whenever a POI is tracked
Off-screen POIs show as arrows pinned around the circular edge Verified — PoiMapArrows.vue gains a boundary prop and computeCircularArrow, which clamps to the inscribed circle less a 24 px inset, and now recomputes on rotate as well as move
The mission line and vehicle trail move into shared composables that the map widget and mission planning use too Verified — Map.vue (base :1322-1444) and MissionPlanningView.vue (base :4445-4508) both delete their local polyline code in favour of useMapMissionLayer / useMapVehiclePathLayer
The map widget's "Show vehicle path" switch starts working Verified — see Failure site
Mission planning's mission line changes colour to the shared blue Verified — useMapMissionLayer.ts:7 defaults to #358AC3 and MissionPlanningView.vue passes no color; announced in the PR body and in commit 91ad319's message
leaflet-rotate is GPL-3.0 and bundled in both distributions Verified — package.json:74, yarn.lock, and the third-party table in README.md:341-348
The heading source now decides its own conversion, like the coordinates do Verified — heading.ts:12-15,28, MiniMap.vue:246-249
The variable-id lookup rule now lives once Verified — source-options.ts:19-26, consumed by altitude.ts, position.ts and heading.ts
The ContextMenu prop rename covers three call sites, not two Verified — WidgetHugger.vue:39, MiniWidgetInstantiator.vue:9, Map.vue:107

Failure site — the "Show vehicle path" bug is real, and its cause is where the PR says it is: in the base checkout, src/components/widgets/Map.vue:1414-1444 gates the trail watcher on the map, the vehicle marker and the point count but never reads widget.value.options.showVehiclePath, and :891-903 draws the trail at mount with the same omission. Both sites are in the diff; the option is now consulted in the composable's show getter (show: () => widget.value.options.showVehiclePath && vehicleMarker.value !== undefined), which is watched, so toggling takes effect immediately instead of on the next position update.

Entry points

Function Reached from Frequency
findSourceOptionForVariableId (source-options.ts:19) rawAltitudeToMeters / isPresetAltitudeVariableId (altitude.ts:49,60), rawCoordinateToDegrees (position.ts:34), rawHeadingToDegrees (heading.ts:28) per incoming message
rawHeadingToDegrees (heading.ts:28) vehicleHeading computed (MiniMap.vue:246) → the heading watcher at useMiniMap.ts:237, throttled to 16 ms per incoming message
rawCoordinateToDegrees (position.ts:34) vehiclePosition computed (MiniMap.vue:243) → the recenter watcher, throttled to 16 ms per incoming message
rawAltitudeToMeters / isPresetAltitudeVariableId (altitude.ts) DepthHUD.vue:183, DepthIndicator.vue:128, RelativeAltitudeIndicator.vue:124 watchers; useAltitudeSourceConfig.ts:39,143,151 per incoming message (config paths: per user action)
useMapMissionLayer (useMapMissionLayer.ts:36) waypoint watchers in Map.vue, MissionPlanningView.vue and MiniMap.vue:304-308 per user action
useMapVehiclePathLayer (useMapVehiclePathLayer.ts:30) the missionStore.vehiclePositionHistoryRevision watcher in all three surfaces per incoming message
useMiniMap init / destroy MiniMap.vue onMounted / onBeforeUnmount one-shot
useMiniMap bearing tween (animateBearing) the headingUp watcher, i.e. the north-indicator click and the menu toggle per user action
useWidgetContextMenu (useWidgetContextMenu.ts:14) MiniMap.vue's menu-items watcher; read back by WidgetHugger.vue on right-click per user action
vehicleMarkerImageUrl (vehicle-marker.ts:20) Map.vue's vehicleStore.coordinates watcher (first fire only) and MiniMap.vue's marker setup per incoming message
computeCircularArrowclampPointToCircle (minimap-geometry.ts:38) / isInsideCircle (:26) calculatePoiEdgeArrows in PoiMapArrows.vue, driven by the map's move and rotate handlers (debounced) per frame or pointer event
buildRadialFadeMask (minimap-geometry.ts:67) applyFadeMask at init and on the edgeFadeAmount watcher per user action
isLeafletMapReady (utils-map.ts:35) the PoI-marker, mission-layer and vehicle-path composables per incoming message
minWidth computed (ContextMenu.vue:108) the menu's style binding, rendered from WidgetHugger.vue:39, MiniWidgetInstantiator.vue:9, Map.vue:107 per user action

No row is never: every new export has a call site in this PR, defaultHeadingVariableId included (MiniMap.vue:212).

Invariants

  • A stored data-lake variable id determines its own unit conversion. The rule now sits in one table per quantity (position.ts, heading.ts, altitude.ts) behind one matcher (source-options.ts:19-26). What could break it is a widget converting those same ids by hand; the grep for rawAltitudeToMeters / isPresetAltitudeVariableId finds only the three altitude widgets and useAltitudeSourceConfig, all going through the table, and the MiniMap is the only consumer of the position and heading tables. Covered.
  • One polyline per layer per map, created and torn down in one place. The composables own creation, in-place setLatLngs updates and removal (onBeforeUnmount, plus clear() when show() goes false), which is what let Map.vue's colour-matching removal in clearMapDrawing go away. Every base reference to the removed locals falls inside a removed hunk, verified by grep in both files.
  • leaflet-rotate needs a global L before its module body evaluates. Established at a single chokepoint, ensureRotatePlugin (useMiniMap.ts:19-30), which sets the global and then lazily imports the plugin, memoised. Its session-wide effect on the shared Leaflet module was finding 1.3, now resolved by the maintainer as intended.
  • ContextMenu's width prop is a minimum, not a fixed width. Established by pairing it with width: 'max-content'; all three consumers renamed, and no other importer exists.
2. Persistence & User Data — inventory, no findings
Key / shape Backend Change
WidgetType.MiniMap widget options (vehicleLatitudeVariableId, vehicleLongitudeVariableId, vehicleYawVariableId, trackTarget, trackedPoiId, tileProvider, edgeFadeAmount, headingUp, showPois, showNorthIndicator, showMission, showVehiclePath, defaultZoom) vehicle-synced, via the widget-manager profile stored with useBlueOsStorage Added
WidgetManagerVars.contextMenuItems not persisted — the runtime-only vars map in stores/widgetManager.ts Added
WidgetType.Map option showVehiclePath vehicle-synced, same profile storage Existing key, now actually read

Judging each: the MiniMap options are all view preferences rather than machine-specific values, so vehicle-syncing them is the right backend and matches every other widget; they are merged over defaults on mount (MiniMap.vue:225-227, the { ...defaultOptions, ...widget.value.options } pattern), so a widget saved by an older build gains the new entries instead of reading undefined; trackedPoiId stores null rather than undefined (finding 2.2, addressed in round 4) so it survives the JSON round-trip; no value repeats its own key; nothing is reshaped and nothing is removed, so there is no migration and none is needed. The one behaviour change to an existing persisted value is showVehiclePath starting to be honoured — users who had switched it off will lose the trail they were still seeing. That is stated in the PR body and in commit a2a08dcd's message, which is the explicit decision this section asks for.

Sections with nothing to report (10)

1. Correctness & Implementation Bugs — ✅ (the heading path now converts by stored id, heading.ts:28MiniMap.vue:246; both new widgets read telemetry through useDataLakeVariable rather than the vehicle store; MiniMap options merge over defaults at MiniMap.vue:225-227; all MiniMap state is created inside <script setup>, so two instances share nothing; no Electron-only API appears on these paths)

3. AGENTS.md Adherence — ✅ (leaflet-rotate sits alphabetically at package.json:74; every added export has a call site in this PR, defaultHeadingVariableId included; no added JSDoc block is empty after the round-1 fix; this round's two new modules are the shared helpers findings 1.2 and 7.1 asked for, not new abstractions)

4. Security — ✅ (the two files added this round carry no network call, no eval, no encoded blob and no hidden Unicode; MAVLINK_PATH_PATTERN is linear with no backtracking hazard; the only new dependency remains leaflet-rotate, pinned in yarn.lock to the public registry with an integrity hash, and its licence obligation was finding 4.2, resolved)

5. Performance — ✅ (findSourceOptionForVariableId does the same two regex matches and small-table scan the inlined altitude.ts version did, so the per-message altitude path is unchanged in cost; the heading path adds one such call per message against a two-entry table, behind a 16 ms throttle; the vehicle trail still appends only new points onto a dedicated Canvas renderer; every listener, ResizeObserver and animation frame useMiniMap creates is released in destroy, which onBeforeUnmount calls)

6. UI / UX — ✅ (the settings dialog keeps the centered title and footer separator from round 1; the context-menu toggles each log a past-tense logUserAction naming the target, e.g. Showed the mission on the MiniMap; the min-width rename leaves both existing menus at the same 200 px, now able to grow instead of wrapping)

7. Code Quality & Style — ✅ (the suffix matcher is extracted once and all three tables consume it; no comment was reworded over unchanged code; heading.ts and position.ts keep the conversions out of the .vue file, in framework-agnostic modules; the widget lands in a new file rather than growing Map.vue, which this PR shrinks by 60 net lines)

8. Commit Hygiene — ✅ (ten commits, each scope-prefixed in this repository's style; the new 8848be93 is the shared-lookup extraction landed ahead of its consumer, as finding 7.1 asked; 3848930's message now states the prop rename and its three call sites; the two behaviour changes still ride alone, in a2a08dcd and in the announced colour change in 91ad319; no wip, fixup! or self-reverting commit)

9. Tests — ✅ (src/tests/libs/map/minimap-geometry.test.ts is unchanged since round 3 and still covers the three geometry helpers, boundary and clamping cases included; no existing test was removed or weakened)

10. Documentation — ✅ (the README third-party table names leaflet-rotate with its licence, its source and which distributions bundle it; LICENSE.md states that neither arm of the dual offer sublicenses the copyleft components; both new data-source functions carry typed @param/@returns JSDoc)

11. Nitpicks / Optional — ✅ (11.3 was the last one open and is addressed; Map.vue:107 keeps its pre-existing :min-width="'260px'" binding form rather than being reflowed, which is the right call for scope)

Generated by Claude. This is advisory; a human reviewer must still approve.

@ArturoManzoli

Copy link
Copy Markdown
Contributor Author

Ready to review

@ES-Alexander ES-Alexander added the docs-needed Change needs to be documented label Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs-needed Change needs to be documented

Projects

None yet

Development

Successfully merging this pull request may close these issues.

frontend: map: allow round shape and faded edges

3 participants