Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,8 @@ export class AppComponent {
weatherApi: false,
radarApi: false,
notificationApi: false,
buddyList: false
buddyList: false,
tidalApi: false
};
this.signalk.get('/signalk/v2/features?enabled=1').subscribe(
(res: {
Expand Down Expand Up @@ -867,6 +868,11 @@ export class AppComponent {
this.app.debug('*** found PMTiles plugin');
hasPlugin.pmTiles = true;
}
// tidal currents
if (p.id === 'signalk-tidal-currents') {
this.app.debug('*** found signalk-tidal-currents plugin');
ff.tidalApi = true;
}
});
this.app.featureFlags.update((current) => {
return Object.assign({}, current, ff);
Expand Down
9 changes: 7 additions & 2 deletions src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,8 @@ export function cleanConfig(
resourceSets: {},
infolayers: null,
weatherWindEnabled: false,
oceanCurrentsEnabled: false
oceanCurrentsEnabled: false,
tidalCurrentsEnabled: false
};
}

Expand Down Expand Up @@ -413,6 +414,9 @@ export function cleanConfig(
if (typeof settings.selections.oceanCurrentsEnabled === 'undefined') {
settings.selections.oceanCurrentsEnabled = false;
}
if (typeof settings.selections.tidalCurrentsEnabled === 'undefined') {
settings.selections.tidalCurrentsEnabled = false;
}

// ensure legacy notes selections section is removed
if (typeof (settings as any).selections.notes) {
Expand Down Expand Up @@ -618,7 +622,8 @@ export function defaultConfig(): IAppConfig {
resourceSets: {}, // additional resources
infolayers: null,
weatherWindEnabled: false,
oceanCurrentsEnabled: false
oceanCurrentsEnabled: false,
tidalCurrentsEnabled: false
}
};
}
Expand Down
4 changes: 3 additions & 1 deletion src/app/app.facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export class AppFacade extends InfoService {
resourceTracks: boolean;
infoLayers: boolean;
buddyList: boolean;
tidalApi: boolean;
}>({
anchorApi: true, // default true until API is available
autopilotApi: false,
Expand All @@ -235,7 +236,8 @@ export class AppFacade extends InfoService {
resourceGroups: false, // ability to store resource groups
resourceTracks: false, // ability to store track resources
infoLayers: false, // ability to store map information overlays
buddyList: false
buddyList: false,
tidalApi: false
});

selfLines = signal<{ cog: LineStyleDef; heading: LineStyleDef }>({
Expand Down
6 changes: 6 additions & 0 deletions src/app/modules/map/fb-map.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,12 @@
>
</fb-weather-currents>

<fb-tidal-currents
[show]="app.config.selections.tidalCurrentsEnabled"
[opacity]="0.7"
>
</fb-tidal-currents>

<!-- chart boundaries-->
@if (app.data.chartBounds.show) {
<fb-chart-bounds
Expand Down
12 changes: 11 additions & 1 deletion src/app/modules/map/fb-map.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ import { MapService } from './ol/lib/map.service';
import { AppIconDef } from '../icons';
import { LayerWindWeatherComponent } from './ol/lib/resources/layer-wind-weather.component';
import { LayerCurrentsWeatherComponent } from './ol/lib/resources/layer-currents-weather.component';
import { TidalCurrentsLayerComponent } from './ol/lib/resources/tidal-currents-layer.component';

interface IResource {
id: string;
Expand Down Expand Up @@ -172,7 +173,8 @@ enum INTERACTION_MODE {
VesselPopoverComponent,
S57PopoverComponent,
LayerWindWeatherComponent,
LayerCurrentsWeatherComponent
LayerCurrentsWeatherComponent,
TidalCurrentsLayerComponent
],
templateUrl: './fb-map.component.html',
styleUrls: ['./fb-map.component.css']
Expand Down Expand Up @@ -1293,6 +1295,14 @@ export class FBMapComponent implements OnInit, OnDestroy {
aircraft = this.app.data.aircraft.get(id);
text = aircraft ? aircraft.name || aircraft.mmsi : '';
break;
case 'tidal':
addToFeatureList = true;
icon = {
name: 'water',
svgIcon: undefined
};
text = feature.get('name');
break;
Comment on lines +1303 to +1314

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add a case 'tidal' handler in formatPopover to show a popover when tidal features are clicked.

processMapClick correctly adds tidal features to the feature list, but formatPopover (lines 1391–1600) has no case 'tidal' — it falls through to default: return; at line 1598. This means:

  • Single tidal feature click: formatPopover('tidal.0', coord) is called → default: return; → no popover appears.
  • Multi-feature list: Selecting a tidal feature calls formatPopover('tidal.0', coord) → same default: return; → no popover.

The PR objective states click handling is routed through processMapClick for "consistent popovers," but the popover is never shown. A case 'tidal' in formatPopover and a corresponding popover template in the HTML are needed to complete the flow.

🐛 Proposed fix: add `case 'tidal'` to `formatPopover`
       case 'aircraft':
         if (!this.app.data.aircraft.has(id)) {
           return false;
         }
         poData.type = t[0];
         poData.id = id;
         poData.aircraft = this.app.data.aircraft.get(id);
         poData.position = poData.aircraft.position;
         poData.show = true;
         break;
+      case 'tidal':
+        poData.id = id;
+        poData.type = 'tidal';
+        poData.title = 'Tidal Current';
+        poData.position = coord;
+        poData.show = true;
+        poData.readOnly = true;
+        break;
       case 'region':

A corresponding @if (overlay().type === 'tidal') block will also be needed in fb-map.component.html to render the popover content (e.g., displaying feature.get('name')).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/modules/map/fb-map.component.ts` around lines 1298 - 1305,
`formatPopover` currently has no `case 'tidal'`, so tidal clicks still fall
through to the default return and never render a popover even though
`processMapClick` routes them there. Add a `case 'tidal'` branch in
`fb-map.component.ts`’s `formatPopover` to populate the tidal overlay data
(using the existing feature name/details flow), and add the matching `@if
(overlay().type === 'tidal')` popover block in `fb-map.component.html` so
single-feature and list selections both display correctly.

}
} else if (!id && feature.getProperties) {
const props = feature.getProperties();
Expand Down
2 changes: 2 additions & 0 deletions src/app/modules/map/ol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { RadarComponent } from './lib/radar/layer-radar.component';
export * from './lib/util';
export { MapService } from './lib/map.service';
export { S57Service } from './lib/charts/s57.service';
export { TidalCurrentsService } from './lib/tidal-currents.service';

export { ContentComponent } from './lib/content.component';
export { ControlsDirective } from './lib/controls.directive';
Expand Down Expand Up @@ -110,6 +111,7 @@ export { MapStyleJsonChartLayerComponent } from './lib/charts/layer-mapstylejson
export { S57ChartLayerComponent } from './lib/charts/layer-s57-chart.component';
export { ChartBoundsLayerComponent } from './lib/charts/layer-chart-bounds.component';
export { RadarComponent } from './lib/radar/layer-radar.component';
export { TidalCurrentsLayerComponent } from './lib/resources/tidal-currents-layer.component';

const declarations = [
ContentComponent,
Expand Down
235 changes: 235 additions & 0 deletions src/app/modules/map/ol/lib/resources/tidal-currents-layer.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
effect,
inject,
Input,
OnChanges,
OnDestroy,
SimpleChanges
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import Feature from 'ol/Feature';
import Point from 'ol/geom/Point';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import { Fill, Icon, Stroke, Style, Text } from 'ol/style';
import {
Subject,
Subscription,
auditTime,
catchError,
of,
switchMap,
tap
} from 'rxjs';
import { FeatureLike } from 'ol/Feature';
import { fromLonLat, transformExtent } from 'ol/proj';

import { TidalCurrentsService, TidalCurrentGridResponse } from '../tidal-currents.service';
import { MapComponent } from '../map.component';
import { AppFacade } from 'src/app/app.facade';

@Component({
selector: 'ol-map > fb-tidal-currents',
template: '<ng-content></ng-content>',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true
})
export class TidalCurrentsLayerComponent implements OnChanges, OnDestroy {
@Input() show = false;
@Input() opacity = 0.7;

private layer: VectorLayer<VectorSource>;
private source: VectorSource;
private refresh$ = new Subject<void>();
private refreshSub: Subscription;
private readonly destroyRef = inject(DestroyRef);
private readonly zIndex = 50;

constructor(
private mapComponent: MapComponent,
private currents: TidalCurrentsService,
private app: AppFacade,
changeDetectorRef: ChangeDetectorRef
) {
changeDetectorRef.detach();
effect(() => {
this.currents.scrubTime();
if (this.show && this.layer) {
this.refresh$.next();
}
});
}

ngOnChanges(changes: SimpleChanges) {
if (typeof changes.opacity !== 'undefined' && this.layer) {
this.layer.setOpacity(this.normalizedOpacity);
}

if (this.show) {
this.addLayer();
} else {
this.removeLayer();
}
}

ngOnDestroy() {
this.refreshSub?.unsubscribe();
this.removeLayer();
}

private addLayer() {
if (this.layer) {
this.layer.setOpacity(this.normalizedOpacity);
this.refresh$.next();
return;
}

this.source = new VectorSource();
this.layer = new VectorLayer({
source: this.source,
opacity: this.normalizedOpacity,
zIndex: this.zIndex,
visible: this.show,
style: (feature) => this.currentsStyleFunction(feature)
});
this.layer.set('id', 'tidal-currents');
this.mapComponent.getMap().addLayer(this.layer);

this.refreshSub = this.refresh$
.pipe(
auditTime(300),
switchMap(() => this.fetchCurrents())
)
.subscribe();
this.currents.dragEnd$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
if (this.show && this.layer) this.fetchCurrents().subscribe();
});
this.mapComponent.getMap().on('moveend', this.onMoveEnd);
this.refresh$.next();
this.mapComponent.getMap().render();
}

private removeLayer() {
const map = this.mapComponent.getMap();
if (!map || !this.layer) {
return;
}

map.un('moveend', this.onMoveEnd);
this.refreshSub?.unsubscribe();
this.refreshSub = undefined;
map.removeLayer(this.layer);
this.source?.clear();
this.source = undefined;
this.layer = undefined;
map.render();
}

private onMoveEnd = () => {
this.refresh$.next();
};

private get normalizedOpacity() {
return Math.max(0, Math.min(1, this.opacity ?? 1));
}

private fetchCurrents() {
if (!this.show || !this.source) {
return of<TidalCurrentGridResponse | null>(null);
}

const bbox = this.getBbox();
if (!bbox) {
return of<TidalCurrentGridResponse | null>(null);
}

return this.currents.getGridCurrents(bbox, this.currents.selectedTime()).pipe(
tap((response) => this.renderCurrents(response.points)),
catchError(() => {
console.warn('Failed to fetch tidal currents');
return of<TidalCurrentGridResponse | null>(null);
})
);
}

private getBbox(): [number, number, number, number] | null {
const map = this.mapComponent.getMap();
const size = map.getSize();
if (!size) {
return null;
}

const extent = transformExtent(
map.getView().calculateExtent(size),
'EPSG:3857',
'EPSG:4326'
);
return [extent[0], extent[1], extent[2], extent[3]] as [number, number, number, number];
}

private renderCurrents(points: TidalCurrentGridResponse['points']) {
this.source.clear();
const features = points.map((point, index) => {
const feature = new Feature({
geometry: new Point(fromLonLat([point.longitude, point.latitude])),
driftKts: point.speedKn,
setDeg: point.direction
});
feature.setId('tidal.' + index);
const driftMs = point.speedKn / 1.94384;
const speedLabel = this.app.formatValueForDisplay(driftMs, 'm/s', { precision: 1 });
feature.set('name', `Current: ${speedLabel} @ ${point.direction.toFixed(0)}°T`);
return feature;
});
this.source.addFeatures(features);
}

private currentsStyleFunction(feature: FeatureLike) {
const driftKts = Number(feature.get('driftKts')) || 0;
const setDeg = Number(feature.get('setDeg')) || 0;
const setRad = (setDeg * Math.PI) / 180;

let color = '#28a745';
if (driftKts >= 2.0) {
color = '#dc3545';
} else if (driftKts >= 1.0) {
color = '#ffc107';
}

const svg = `
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L19 10H14V22H10V10H5L12 2Z" fill="${color}" stroke="#000" stroke-width="1"/>
</svg>`;

const src = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
const pixelSize = Math.max(16, Math.min(40, 12 + driftKts * 9));
const scale = pixelSize / 24;

const driftMs = driftKts / 1.94384;
const speedLabel = this.app.formatValueForDisplay(driftMs, 'm/s', { precision: 1 });

return new Style({
image: new Icon({
src: src,
rotation: setRad,
scale: scale,
anchor: [0.5, 0.5],
anchorXUnits: 'fraction',
anchorYUnits: 'fraction'
}),
text: new Text({
text: speedLabel,
offsetY: 14,
font: '11px Roboto, Arial, sans-serif',
fill: new Fill({ color: 'rgba(255, 255, 255, 0.95)' }),
stroke: new Stroke({ color: 'rgba(20, 60, 95, 0.9)', width: 3 })
})
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading