diff --git a/CHANGELOG.md b/CHANGELOG.md index 292bf545..7f115307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # CHANGELOG: Freeboard +### v2.12.0 + +- **Added**: Define chart sources from within the Charts List including: WMTS, Mapbox Style and TileJSON. +- **Updated**: Measure distances < 1km are displayed in meters and < 0.5NM uses depth units (#194). +- **Updated**: Ensure weather forecast times use 24 hr format. (#193). +- **Updated**: OpenSea Map min / max zoom levels. +- **Updated**: OpenLayers v10. +- **Fixed**: gybeAngle null value handling. + ### v2.11.5 - **Fixed**: Issue when moving waypoint. diff --git a/helper/weather/openweather.ts b/helper/weather/openweather.ts index c5c787eb..c98f4b07 100644 --- a/helper/weather/openweather.ts +++ b/helper/weather/openweather.ts @@ -117,7 +117,11 @@ export class OpenWeather implements IWeatherService { fetchData = async (position: Position): Promise => { const url = this.getUrl(position); const response = await fetch(url); - return this.parseResponse(response as OWResponse); + if ('cod' in response) { + throw new Error(response.message); + } else { + return this.parseResponse(response as OWResponse); + } }; private parseResponse = (owData: OWResponse): ParsedResponse => { diff --git a/helper/weather/weather-service.ts b/helper/weather/weather-service.ts index 5a6f519a..c4bf2791 100644 --- a/helper/weather/weather-service.ts +++ b/helper/weather/weather-service.ts @@ -380,14 +380,13 @@ const fetchWeatherData = () => { server.debug( `*** Weather: Calling service API.....(attempt: ${retryCount})` ); - server.debug(`Position: ${JSON.stringify(pos.value)}`); server.debug(`*** Weather: polling weather provider.`); weatherService .fetchData(pos.value) .then((data) => { server.debug(`*** Weather: data received....`); - //server.debug(JSON.stringify(data)); + server.debug(JSON.stringify(data)); retryCount = 0; lastFetch = Date.now(); lastWake = Date.now(); @@ -419,7 +418,8 @@ const fetchWeatherData = () => { retryInterval / 1000 } sec)` ); - server.debug(err.message); + console.log(err.message); + server.setPluginError(err.message); // sleep and retry retryTimer = setTimeout(() => fetchWeatherData(), retryInterval); }); diff --git a/package.json b/package.json index 6bf5215b..06a56420 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@signalk/freeboard-sk", - "version": "2.11.5", + "version": "2.12.0", "description": "Openlayers chart plotter implementation for Signal K", "keywords": [ "signalk-webapp", @@ -79,8 +79,8 @@ "karma-jasmine-html-reporter": "^1.5.0", "ng-packagr": "^18.1.0", "ngeohash": "^0.6.3", - "ol": "^9.0.0", - "ol-mapbox-style": "^12.3.3", + "ol": "^10.2.1", + "ol-mapbox-style": "^12.3.5", "pmtiles": "^2.7.0", "prettier": "^2.5.1", "prettier-plugin-organize-attributes": "^0.0.5", diff --git a/src/app/app.component.html b/src/app/app.component.html index 73a4d14f..221b42d7 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -36,6 +36,7 @@ save  Save to GPX + @if(app.config.resources.paths.length !== 0) {
@@ -534,6 +535,7 @@ [charts]="app.data.charts" [selectedCharts]="app.config.selections.charts" (select)="skres.chartSelected()" + (delete)="skres.showChartDelete($event)" (orderChange)="skres.chartOrder()" (refresh)="skres.getCharts()" (closed)="displayLeftMenu()" diff --git a/src/app/app.info.ts b/src/app/app.info.ts index 9489d71a..3006e53d 100644 --- a/src/app/app.info.ts +++ b/src/app/app.info.ts @@ -51,8 +51,8 @@ export const OSM = [ name: 'Sea Map', description: 'Open Sea Map', url: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', - minzoom: 12, - maxzoom: 18, + minzoom: 1, + maxzoom: 24, bounds: [-180, -90, 180, 90], type: 'tilelayer' }), @@ -160,7 +160,7 @@ export class AppInfo extends Info { this.name = 'Freeboard-SK'; this.shortName = 'Freeboard'; this.description = `Signal K Chart Plotter.`; - this.version = '2.11.5'; + this.version = '2.12.0'; this.url = 'https://github.com/signalk/freeboard-sk'; this.logo = './assets/img/app_logo.png'; @@ -657,9 +657,16 @@ export class AppInfo extends Info { ? `${value.toFixed(1)} m` : `${Convert.metersToFeet(value).toFixed(1)} ft`; } else { - return this.config.units.distance !== 'ft' - ? `${(value / 1000).toFixed(1)} km` - : `${Convert.kmToNauticalMiles(value / 1000).toFixed(1)} NM`; + if (this.config.units.distance !== 'ft') { + return value < 1000 + ? `${value.toFixed(0)} m` + : `${(value / 1000).toFixed(1)} km`; + } else { + const nm = Convert.kmToNauticalMiles(value / 1000); + return nm < 0.5 + ? this.formatValueForDisplay(value, 'm', true) + : `${nm.toFixed(1)} NM`; + } } } else if (sourceUnits === 'm/s') { switch (this.config.units.speed) { diff --git a/src/app/app.messages.ts b/src/app/app.messages.ts index 2acb08a5..ec1426dc 100644 --- a/src/app/app.messages.ts +++ b/src/app/app.messages.ts @@ -1,23 +1,21 @@ const WHATS_NEW = [ - { + /*{ type: 'signalk-server-node', - title: 'AIS Vessels', + title: 'Chart Sources', message: ` - The following new features have been added: + Initial support for defining the following chart sources directly + from the Chart List: +
  • WMTS (WebMap Tile Server)
  • +
  • TileJSON
  • +
  • Mapbox Style
  • +
    + Note: This functionality requires an upcoming release of + Signal K Server.
     
    -
  • Ability to Flag vessels.
  • -
  • COG line is now displayed for AIS vessels.
  • + See HELP + for more details. ` - }, - { - type: 'signalk-server-node', - title: 'Racing Support', - message: ` - This release contains initial support for navigation.racing paths. -
     
    -
  • Display start line.
  • - ` - } + }*/ ]; export const WELCOME_MESSAGES = { diff --git a/src/app/modules/map/fb-map.component.ts b/src/app/modules/map/fb-map.component.ts index b4a7bcd4..ab943354 100644 --- a/src/app/modules/map/fb-map.component.ts +++ b/src/app/modules/map/fb-map.component.ts @@ -91,6 +91,7 @@ import { SKNotification } from 'src/app/types'; import { S57Service } from './ol/lib/s57.service'; +import { Position as SKPosition } from '@signalk/server-api'; interface IResource { id: string; @@ -1231,15 +1232,24 @@ export class FBMapComponent implements OnInit, OnDestroy { this.app.data.vessels.self.performance.beatAngle ?? Math.PI / 4 ); - const ga_deg = Convert.radiansToDegrees( - this.app.data.vessels.self.performance.gybeAngle ?? Math.PI / 9 - ); + let ga_deg: number; + let ga_diff: number; + if ( + typeof this.app.data.vessels.self.performance.gybeAngle === 'number' + ) { + ga_deg = Convert.radiansToDegrees( + this.app.data.vessels.self.performance.gybeAngle + ); + ga_diff = Math.abs(180 - ga_deg); + } - const destInTarget = - destUpwind && - Math.abs( - Angle.difference(this.app.data.navData.bearing.value, twd_deg) - ) < ba_deg; + const destInTarget = destUpwind + ? Math.abs( + Angle.difference(this.app.data.navData.bearing.value, twd_deg) + ) < ba_deg + : Math.abs( + Angle.difference(this.app.data.navData.bearing.value, twd_inv) + ) < (ga_diff ?? 0); const dtg = this.app.config.units.distance === 'm' @@ -1265,7 +1275,7 @@ export class FBMapComponent implements OnInit, OnDestroy { this.dfeat.navData.position, [bapt2.longitude, bapt2.latitude] ]; - } else { + } else if (typeof ga_deg === 'number') { const gapt1 = computeDestinationPoint( this.dfeat.navData.position, dtg, @@ -1287,30 +1297,58 @@ export class FBMapComponent implements OnInit, OnDestroy { vl.targetAngle = markLines; // vessel laylines - if (destInTarget && destUpwind) { - // Vector angles + if (destInTarget) { const hbd_deg = Angle.difference( twd_deg, this.app.data.navData.bearing.value ); - const C_RAD = Convert.degreesToRadians(ba_deg - hbd_deg); - const B_RAD = Convert.degreesToRadians(ba_deg + hbd_deg); - const A_RAD = Math.PI - (B_RAD + C_RAD); // Vector lengths - const b = (dtg * Math.sin(B_RAD)) / Math.sin(A_RAD); - const c = (dtg * Math.sin(C_RAD)) / Math.sin(A_RAD); + let b: number; + let c: number; // intersection points - const ipts = computeDestinationPoint( - this.app.data.vessels.active.position, - b, - Angle.add(twd_deg, ba_deg) - ); - const iptp = computeDestinationPoint( - this.app.data.vessels.active.position, - c, - Angle.add(twd_deg, 0 - ba_deg) - ); - + let ipts: SKPosition; + let iptp: SKPosition; + + if (destUpwind) { + // Vector angles + const C_RAD = Convert.degreesToRadians(ba_deg - hbd_deg); + const B_RAD = Convert.degreesToRadians(ba_deg + hbd_deg); + const A_RAD = Math.PI - (B_RAD + C_RAD); + b = (dtg * Math.sin(B_RAD)) / Math.sin(A_RAD); + c = (dtg * Math.sin(C_RAD)) / Math.sin(A_RAD); + // intersection points + ipts = computeDestinationPoint( + this.app.data.vessels.active.position, + b, + Angle.add(twd_deg, ba_deg) + ); + iptp = computeDestinationPoint( + this.app.data.vessels.active.position, + c, + Angle.add(twd_deg, 0 - ba_deg) + ); + } else { + // downwind + if (markLines.length !== 0 && typeof ga_diff === 'number') { + // Vector angles + const C_RAD = Convert.degreesToRadians(ga_diff - hbd_deg); + const B_RAD = Convert.degreesToRadians(ga_diff + hbd_deg); + const A_RAD = Math.PI - (B_RAD + C_RAD); + b = (dtg * Math.sin(B_RAD)) / Math.sin(A_RAD); + c = (dtg * Math.sin(C_RAD)) / Math.sin(A_RAD); + // intersection points + ipts = computeDestinationPoint( + this.app.data.vessels.active.position, + b, + Angle.add(twd_deg, ga_diff) + ); + iptp = computeDestinationPoint( + this.app.data.vessels.active.position, + c, + Angle.add(twd_deg, 0 - ga_diff) + ); + } + } vl.laylines = { port: [ [ diff --git a/src/app/modules/map/mapconfig.ts b/src/app/modules/map/mapconfig.ts index b3da92c4..e31a0c40 100644 --- a/src/app/modules/map/mapconfig.ts +++ b/src/app/modules/map/mapconfig.ts @@ -719,7 +719,7 @@ export const targetAngleStyle = new Style({ stroke: new Stroke({ color: 'gray', width: 1, - lineDash: [5, 5] + lineDash: [15, 5, 3, 5] }) }); diff --git a/src/app/modules/map/ol/lib/resources/layer-aiswind.component.ts b/src/app/modules/map/ol/lib/resources/layer-aiswind.component.ts index 67c4b73f..9e366e5d 100644 --- a/src/app/modules/map/ol/lib/resources/layer-aiswind.component.ts +++ b/src/app/modules/map/ol/lib/resources/layer-aiswind.component.ts @@ -6,8 +6,8 @@ import { SimpleChanges } from '@angular/core'; import { Feature } from 'ol'; -import { Style, Icon, Stroke } from 'ol/style'; -import { LineString, Point } from 'ol/geom'; +import { Style, Stroke } from 'ol/style'; +import { LineString } from 'ol/geom'; import { fromLonLat } from 'ol/proj'; import { MapComponent } from '../map.component'; import { AISBaseLayerComponent } from './ais-base.component'; @@ -75,10 +75,8 @@ export class AISWindLayerComponent extends AISBaseLayerComponent { } const f = new Feature(new LineString(v)); f.setId('wind-' + id); - //const s = this.buildStyle('').clone(); - //f.setStyle(this.setRotation(s, target.orientation)); f.setStyle(this.buildVectorStyle()); - this.source.addFeature(f); + this.source?.addFeature(f); } } @@ -93,19 +91,6 @@ export class AISWindLayerComponent extends AISBaseLayerComponent { }); } - /*private buildStyle(label?: string) { - return new Style({ - image: new Icon({ - src: './assets/img/ais_flag.svg', - rotateWithView: true, - scale: 0.2, - anchor: [27, 187], - anchorXUnits: 'pixels', - anchorYUnits: 'pixels' - }) - }); - }*/ - // reload all Features from this.targets override onReloadTargets() { this.extractKeys(this.targets).forEach((id) => { @@ -117,7 +102,7 @@ export class AISWindLayerComponent extends AISBaseLayerComponent { override onUpdateTargets(ids: Array) { ids.forEach((id: string) => { if (id.includes(this.targetContext)) { - const f = this.source.getFeatureById('wind-' + id) as Feature; + const f = this.source?.getFeatureById('wind-' + id) as Feature; if (this.okToRenderTarget(id)) { if (this.targets.has(id)) { if (f) { diff --git a/src/app/modules/map/ol/lib/resources/layer-charts.component.ts b/src/app/modules/map/ol/lib/resources/layer-charts.component.ts index f00b6b09..3eca59ba 100644 --- a/src/app/modules/map/ol/lib/resources/layer-charts.component.ts +++ b/src/app/modules/map/ol/lib/resources/layer-charts.component.ts @@ -192,7 +192,7 @@ export class FreeboardChartLayerComponent : charts[i][1].minZoom; const maxZ = charts[i][1].maxZoom; - if (charts[i][1].type === 'mapstyleJSON') { + if (charts[i][1].type.toLowerCase() === 'mapboxstyle') { const lg = new LayerGroup({ zIndex: this.zIndex + parseInt(i) }); @@ -208,7 +208,7 @@ export class FreeboardChartLayerComponent charts[i][1] ); layer = styleFactory.CreateLayer(); - styleFactory.ApplyStyle(layer as VectorTileLayer); + styleFactory.ApplyStyle(layer as VectorTileLayer); layer.setZIndex(this.zIndex + parseInt(i)); } else { // raster tile @@ -266,7 +266,8 @@ export class FreeboardChartLayerComponent ) { // tileJSON source = new TileJSON({ - url: charts[i][1].url + url: charts[i][1].url, + crossOrigin: 'anonymous' }); } else { // XYZ tilelayer diff --git a/src/app/modules/map/ol/lib/vectorLayerStyleFactory.ts b/src/app/modules/map/ol/lib/vectorLayerStyleFactory.ts index 7ce0df18..7cd52ea2 100644 --- a/src/app/modules/map/ol/lib/vectorLayerStyleFactory.ts +++ b/src/app/modules/map/ol/lib/vectorLayerStyleFactory.ts @@ -24,8 +24,8 @@ export abstract class VectorLayerStyler { this.MaxZ = chart.maxZoom; } - public abstract ApplyStyle(vectorLayer: VectorTileLayer); - public abstract CreateLayer(): VectorTileLayer; + public abstract ApplyStyle(vectorLayer: VectorTileLayer); + public abstract CreateLayer(): VectorTileLayer; } class S57LayerStyler extends VectorLayerStyler { @@ -33,7 +33,7 @@ class S57LayerStyler extends VectorLayerStyler { super(chart); } - public CreateLayer(): VectorTileLayer { + public CreateLayer(): VectorTileLayer { let extent: Extent = null; if (this.chart.bounds && this.chart.bounds.length > 0) { extent = transformExtent(this.chart.bounds, 'EPSG:4326', 'EPSG:3857'); @@ -41,7 +41,7 @@ class S57LayerStyler extends VectorLayerStyler { return new VectorTileLayer({ declutter: true, extent: extent }); } - public ApplyStyle(vectorLayer: VectorTileLayer) { + public ApplyStyle(vectorLayer: VectorTileLayer) { const source = new VectorTileSource({ url: this.chart.url, minZoom: this.chart.minZoom, @@ -52,7 +52,7 @@ class S57LayerStyler extends VectorLayerStyler { const style = new S57Style(this.s57service); - vectorLayer.setSource(source); + vectorLayer.setSource(source as never); vectorLayer.setPreload(0); vectorLayer.setStyle(style.getStyle); vectorLayer.setMinZoom(this.chart.minZoom); @@ -70,7 +70,7 @@ class DefaultLayerStyler extends VectorLayerStyler { super(chart); } - public CreateLayer(): VectorTileLayer { + public CreateLayer(): VectorTileLayer { return new VectorTileLayer(); } @@ -87,7 +87,7 @@ class DefaultLayerStyler extends VectorLayerStyler { }); } - public ApplyStyle(vectorLayer: VectorTileLayer) { + public ApplyStyle(vectorLayer: VectorTileLayer) { // mbtiles source const source = new VectorTileSource({ url: this.chart.url, @@ -99,7 +99,7 @@ class DefaultLayerStyler extends VectorLayerStyler { }) }); - vectorLayer.setSource(source); + vectorLayer.setSource(source as never); vectorLayer.setPreload(0); vectorLayer.setStyle(this.applyVectorStyle); vectorLayer.setMinZoom(this.MinZ); @@ -112,11 +112,11 @@ class PMLayerStyler extends DefaultLayerStyler { super(chart); } - public CreateLayer(): VectorTileLayer { + public CreateLayer(): VectorTileLayer { return new VectorTileLayer({ declutter: true }); } - public ApplyStyle(vectorLayer: VectorTileLayer) { + public ApplyStyle(vectorLayer: VectorTileLayer) { vectorLayer.set('declutter', true); const tiles = new pmtiles.PMTiles(this.chart.url); @@ -154,7 +154,7 @@ class PMLayerStyler extends DefaultLayerStyler { tileLoadFunction: loader }); - vectorLayer.setSource(source); + vectorLayer.setSource(source as never); vectorLayer.setPreload(0); vectorLayer.setStyle(this.applyVectorStyle); vectorLayer.setMinZoom(this.chart.minZoom); diff --git a/src/app/modules/skresources/components/charts/chartlist.html b/src/app/modules/skresources/components/charts/chartlist.html index 6206982e..2b6b37b7 100644 --- a/src/app/modules/skresources/components/charts/chartlist.html +++ b/src/app/modules/skresources/components/charts/chartlist.html @@ -1,4 +1,10 @@
    + + + +
    @@ -93,6 +99,15 @@ import_export Re-order + + } @@ -140,6 +155,18 @@
    + @if(r[1].source && r[1].source === 'resources-provider') { +
    + +
    + }
    + + + + + Map Server host. + + @if (txturl) { + + } + Enter url of the Map Server. + @if (txturl.invalid) { + Map server host url is required! + } + + @if (isFetching) { + + } @else { @if (errorMsg) { + Error retrieving data from server! + } @else { +
    + @if (provider) { +
    +
    +
    +
    Source:
    +
    {{ details.type }}
    +
    +
    +
    Name:
    +
    {{ details.name }}
    +
    +
    +
    Version:
    +
    {{ details.version }}
    +
    +
    +
    Layers:
    +
    + @for (l of details.layers; track l) { +
    {{ l }}
    + } +
    +
    +
    + } +
    + } } +
    + + + +
    + `, + styles: [ + ` + ._ap-mapbox { + } + ._ap-mapbox .row { + display: flex; + } + ._ap-mapbox .label { + width: 70px; + font-weight: 500; + } + ._ap-mapbox .value { + flex: 1 1 auto; + } + ` + ] +}) +export class JsonMapSourceDialog { + protected isFetching = false; + protected fetchError = false; + protected errorMsg = ''; + protected hostUrl = ''; + protected provider!: ChartProvider; + protected details: { + type: string; + name: string; + version: string; + layers: string[]; + }; + + constructor( + public app: AppInfo, + public dialogRef: MatDialogRef, + private http: HttpClient, + @Inject(MAT_DIALOG_DATA) public data: SKChart + ) {} + + handleSave() { + this.dialogRef.close([this.provider]); + } + + /** Fetch the mapbox JSON file contents */ + getJsonFile(uri: string) { + this.errorMsg = ''; + this.fetchError = false; + this.isFetching = true; + this.provider = undefined; + this.http.get(uri).subscribe( + (res: TileJson | MapboxStyle) => { + this.isFetching = false; + if (!res.name) { + this.errorMsg = 'Invalid response received!'; + } else { + const c = this.parseFileContents(res, uri); + if (c) { + this.provider = c as ChartProvider; + this.details = { + type: (res as TileJson).tilejson ? 'TileJSON' : 'Mapbox Style', + name: res.name, + version: (res as MapboxStyle).version + ? (res as MapboxStyle).version + : (res as TileJson).tilejson, + layers: (res as MapboxStyle).layers + ? (res as MapboxStyle).layers.map((l) => l.id) + : (res as TileJson).vector_layers + ? (res as TileJson).vector_layers.map((l) => l.id) + : [] + }; + } else { + this.fetchError = true; + this.errorMsg = 'Invalid file contents!'; + } + } + }, + (err: HttpErrorResponse) => { + this.isFetching = false; + this.fetchError = true; + this.errorMsg = err.message; + } + ); + } + + parseFileContents(json: TileJson | MapboxStyle, uri: string) { + if ('tilejson' in json) { + return { + name: json.name ?? '', + description: json.description ?? '', + type: 'tilejson', + url: uri + }; + } else if ('version' in json && 'sources' in json && 'layers' in json) { + return { + name: json.name ?? '', + description: '', + type: 'mapboxstyle', + url: uri + }; + } else { + return undefined; + } + } +} diff --git a/src/app/modules/skresources/components/charts/wmts-dialog.ts b/src/app/modules/skresources/components/charts/wmts-dialog.ts new file mode 100644 index 00000000..e153ce55 --- /dev/null +++ b/src/app/modules/skresources/components/charts/wmts-dialog.ts @@ -0,0 +1,257 @@ +import { Component, Inject } from '@angular/core'; +import { + MatDialogModule, + MatDialogRef, + MAT_DIALOG_DATA +} from '@angular/material/dialog'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatIconModule } from '@angular/material/icon'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatInputModule } from '@angular/material/input'; +import { MatListModule, MatSelectionListChange } from '@angular/material/list'; +import { AppInfo } from 'src/app/app.info'; +import { SKChart } from 'src/app/modules/skresources/resource-classes'; +import { PipesModule } from 'src/app/lib/pipes'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { parseString } from 'xml2js'; +import { ChartProvider } from 'src/app/types'; + +/********* WMTSDialog ********** + data: +***********************************/ +@Component({ + selector: 'wmts-dialog', + standalone: true, + imports: [ + MatTooltipModule, + MatIconModule, + MatCardModule, + MatButtonModule, + MatToolbarModule, + MatDialogModule, + MatProgressBarModule, + MatInputModule, + MatListModule, + PipesModule + ], + template: ` +
    + + public + Add WMTS Source + + + + + + @if (true) { + + WMTS host. + + @if (txturl) { + + } + Enter url of the WMTS host. + @if (txturl.invalid) { + WMTS host is required! + } + + } @if (isFetching) { + + } @else { @if (errorMsg) { + Error retrieving capabilities from server! + } @else { +
    + @if (wmtsLayers.length > 0) { +
    + + @for(layer of wmtsLayers; track layer; let idx = $index) { + + {{ layer.name }} + {{ layer.description }} + + } + +
    +

    + Selected: {{ wlayers.selectedOptions.selected.length }} of + {{ wmtsLayers.length }} +

    + } +
    + } } +
    + + + +
    + `, + styles: [ + ` + ._ap-wmts { + } + ._ap-wmts .key-label { + width: 150px; + font-weight: 500; + } + ` + ] +}) +export class WMTSDialog { + protected isFetching = false; + protected fetchError = false; + protected errorMsg = ''; + protected wmtsLayers: Array = []; + protected selections: Array = []; + protected selectionInfo: Array<{ name: string; description: string }> = []; + protected hostUrl = ''; + + constructor( + public app: AppInfo, + public dialogRef: MatDialogRef, + private http: HttpClient, + @Inject(MAT_DIALOG_DATA) public data: SKChart + ) {} + + handleSelection(e: MatSelectionListChange) { + this.selections = e.source.selectedOptions.selected.map((opt) => opt.value); + } + + handleSave() { + const sources: Array = this.selections.map( + (layerIdx) => this.wmtsLayers[layerIdx] + ); + this.dialogRef.close(sources); + } + + /** Make requests to WMTS server */ + wmtsGetCapabilities(wmtsHost: string) { + this.selections = []; + this.selectionInfo = []; + this.wmtsLayers = []; + this.errorMsg = ''; + + const url = wmtsHost + `?request=GetCapabilities&service=wmts`; + this.isFetching = true; + this.http.get(url, { responseType: 'text' }).subscribe( + (res: string) => { + this.isFetching = false; + if (res.indexOf(' { + this.isFetching = false; + this.fetchError = true; + this.errorMsg = err.message; + } + ); + } + + /** Parse WMTSCapabilities.xml */ + parseCapabilities(xml: string, urlBase: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parseString(xml, (err: Error, result: any) => { + if (err) { + this.errorMsg = 'ERROR parsing XML!'; + console.log('ERROR parsing XML!', err); + } else { + this.wmtsLayers = this.getWMTSLayers(result, urlBase).sort((a, b) => + a.name < b.name ? -1 : 1 + ); + } + }); + } + + /** Retrieve the available layers from WMTS Capabilities metadata */ + getWMTSLayers( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + json: { [key: string]: any }, + urlBase: string + ): ChartProvider[] { + const maps: ChartProvider[] = []; + if (!json.Capabilities.Contents[0].Layer) { + return maps; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + json.Capabilities.Contents[0].Layer.forEach((layer: any) => { + const ch = this.parseLayerEntry(layer, urlBase); + if (ch) { + maps.push(ch); + } + }); + return maps; + } + + /** Parse WMTS layer entry */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parseLayerEntry(layer: any, urlBase: string): ChartProvider | null { + if ( + layer['ows:Identifier'] && + Array.isArray(layer['ows:Identifier']) && + layer['ows:Identifier'].length > 0 + ) { + const l: ChartProvider = { + name: layer['ows:Title'] ? layer['ows:Title'][0] : '', + description: layer['ows:Abstract'] ? layer['ows:Abstract'][0] : '', + type: 'WMTS', + url: `${urlBase}`, + layers: [layer['ows:Identifier'][0]] + }; + if ( + layer['ows:WGS84BoundingBox'] && + layer['ows:WGS84BoundingBox'].length > 0 + ) { + l.bounds = [ + Number( + layer['ows:WGS84BoundingBox'][0]['ows:LowerCorner'][0].split(' ')[0] + ), + Number( + layer['ows:WGS84BoundingBox'][0]['ows:LowerCorner'][0].split(' ')[1] + ), + Number( + layer['ows:WGS84BoundingBox'][0]['ows:UpperCorner'][0].split(' ')[0] + ), + Number( + layer['ows:WGS84BoundingBox'][0]['ows:UpperCorner'][0].split(' ')[1] + ) + ]; + } + if (layer['Format'] && layer['Format'].length > 0) { + const f = layer['Format'][0]; + l.format = f.indexOf('jpg') !== -1 ? 'jpg' : 'png'; + } else { + l.format = 'png'; + } + return l; + } else { + return null; + } + } +} diff --git a/src/app/modules/skresources/index.ts b/src/app/modules/skresources/index.ts index 283c971a..a7b51a06 100644 --- a/src/app/modules/skresources/index.ts +++ b/src/app/modules/skresources/index.ts @@ -18,6 +18,8 @@ export * from './components/ais/aircraft-properties-modal'; export * from './components/charts/chartlist'; export * from './components/charts/chart-properties-dialog'; +export * from './components/charts/wmts-dialog'; +export * from './components/charts/jsonmapsource-dialog'; export * from './components/tracks/track-list-modal'; diff --git a/src/app/modules/skresources/resource-classes.ts b/src/app/modules/skresources/resource-classes.ts index 16e955bc..061a7f10 100644 --- a/src/app/modules/skresources/resource-classes.ts +++ b/src/app/modules/skresources/resource-classes.ts @@ -127,6 +127,7 @@ export class SKChart { maxZoom = 24; type: string; url: string; + source: string; constructor(chart?: ChartResource) { this.identifier = chart?.identifier ? chart.identifier : undefined; @@ -145,7 +146,7 @@ export class SKChart { typeof chart?.scale !== 'undefined' && !isNaN(chart?.scale) ? chart.scale : this.scale; - this.url = chart?.url ? chart.url : undefined; + this.source = chart?.$source ?? undefined; } } diff --git a/src/app/modules/skresources/resources.service.ts b/src/app/modules/skresources/resources.service.ts index 2b1a24f4..bf523dd5 100644 --- a/src/app/modules/skresources/resources.service.ts +++ b/src/app/modules/skresources/resources.service.ts @@ -214,7 +214,7 @@ export class SKResources { * @param collection The resource collection to which the resource belongs e.g. routes, waypoints, etc. * @param id Resource identifier */ - public deleteResource(collection: string, id: string) { + public deleteResource(collection: string, id: string, refresh?: boolean) { this.signalk.api .delete(this.app.skApiVersion, `/resources/${collection}/${id}`) .subscribe( @@ -226,6 +226,9 @@ export class SKResources { const idx = this.app.config.selections[collection].indexOf(id); this.app.config.selections[collection].splice(idx, 1); } + if (refresh && collection === 'charts') { + this.getCharts(); + } }, (err: HttpErrorResponse) => { if (err.status && err.status === 401) { @@ -815,6 +818,22 @@ export class SKResources { } } + // ** Confirm Chart Deletion ** + showChartDelete(e: { id: string }) { + this.app + .showConfirm( + 'Do you want to delete this Chart source?\n', + 'Delete Chart:', + 'YES', + 'NO' + ) + .subscribe((result: { ok: boolean }) => { + if (result && result.ok) { + this.deleteResource('charts', e.id, true); + } + }); + } + OSMCharts = [].concat(OSM); // ** get charts from sk server getCharts(apiVersion = this.app.config.chartApi ?? 1) { diff --git a/src/app/modules/weather/weather-forecast-modal.ts b/src/app/modules/weather/weather-forecast-modal.ts index 757b0894..b75ca4c5 100644 --- a/src/app/modules/weather/weather-forecast-modal.ts +++ b/src/app/modules/weather/weather-forecast-modal.ts @@ -137,7 +137,8 @@ export class WeatherForecastModal implements OnInit { .forEach((v: any) => { const forecastData: WeatherData = { wind: {} }; forecastData.description = v['description'] ?? ''; - forecastData.time = new Date(v['date']).toLocaleTimeString() ?? ''; + const d = new Date(v['date']); + forecastData.time = d ? `${d.getHours()}:${d.getMinutes()}:00` : ''; if (typeof v.outside?.temperature !== 'undefined') { forecastData.temperature = diff --git a/src/app/types/resources/signalk.ts b/src/app/types/resources/signalk.ts index 77fbe87f..eb420c3a 100644 --- a/src/app/types/resources/signalk.ts +++ b/src/app/types/resources/signalk.ts @@ -64,8 +64,22 @@ export interface ChartResource { scale?: number; url?: string; layers?: string[]; + $source?: string; //v1 tilemapUrl?: string; // replaced by url chartLayers?: string[]; // replaced by layers serverType?: string; // replaced by type } + +export interface ChartProvider { + identifier?: string; + name: string; + description: string; + type: 'WMTS' | 'mapboxstyle'; + url: string; + layers?: string[]; + bounds?: number[]; + minzoom?: number; + maxzoom?: number; + format?: string; +} diff --git a/src/assets/help/img/chart_list.png b/src/assets/help/img/chart_list.png index b6d7b338..d42bbf26 100644 Binary files a/src/assets/help/img/chart_list.png and b/src/assets/help/img/chart_list.png differ diff --git a/src/assets/help/index.html b/src/assets/help/index.html index 5cf2a379..c7d0113c 100644 --- a/src/assets/help/index.html +++ b/src/assets/help/index.html @@ -1021,6 +1021,17 @@

    layers Resources / Layers

    of the listed charts. The bounding rectangle of each chart will be overlayed on the current map display. +
  • + Add Chart Source: Click + add to add a new chart source + (i.e. WMTS, TileJSON, Mapbox Style). See the + + Freeboard-SK Wiki + + for more details. +
  • Properties: Click info_outline to display the