diff --git a/android/build.gradle b/android/build.gradle index f5ddb3640..05bdf2a52 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -30,7 +30,14 @@ dependencies { implementation "net.sf.marineapi:marineapi:0.11.0" implementation 'androidx.core:core:1.10.1' implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.github.felHR85:UsbSerial:6.1.0' + //see https://github.com/felHR85/UsbSerial/issues/375 + implementation('com.github.felHR85:UsbSerial:7ad6c9f6'){ + artifact { + name = 'UsbSerial' + classifier = 'release' + type = 'aar' + } + } implementation group: 'net.straylightlabs', name: 'hola', version: '0.2.2' implementation group: 'org.apache.httpcomponents', name: 'httpcore', version: '4.0' implementation 'androidx.documentfile:documentfile:1.0.1' diff --git a/android/src/main/java/de/wellenvogel/avnav/appapi/IconRequestHandler.java b/android/src/main/java/de/wellenvogel/avnav/appapi/IconRequestHandler.java new file mode 100644 index 000000000..ba5942f68 --- /dev/null +++ b/android/src/main/java/de/wellenvogel/avnav/appapi/IconRequestHandler.java @@ -0,0 +1,135 @@ +package de.wellenvogel.avnav.appapi; + +import android.content.Context; +import android.net.Uri; +import android.os.Build; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Date; +import java.util.Enumeration; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import androidx.annotation.NonNull; +import de.wellenvogel.avnav.main.BuildConfig; +import de.wellenvogel.avnav.util.AvnLog; +import de.wellenvogel.avnav.util.AvnUtil; +import de.wellenvogel.avnav.worker.GpsService; +import de.wellenvogel.avnav.worker.Worker; + +public class IconRequestHandler extends Worker implements INavRequestHandler{ + protected String urlPrefix; + protected String type; + protected Context context; + JSONArray iconFiles; + static final String ICONBASE="viewer/images"; + public IconRequestHandler(String type, GpsService ctx,String urlPrefrix) throws IOException { + super(type,ctx); + this.type=type; + this.urlPrefix=urlPrefrix; + this.context=ctx; + iconFiles=new JSONArray(); + try { + for (String name : ctx.getAssets().list(ICONBASE)) { + JSONObject item = new JSONObject(); + item.put("name", name); + item.put("url", "/"+urlPrefrix+"/"+name); + item.put("canDelete",false); + item.put("mtime", BuildConfig.TIMESTAMP/1000); + iconFiles.put(item); + } + }catch(Throwable t){ + AvnLog.e("unable to read system icons"); + } + + } + + private boolean isValidName(String name) throws JSONException { + for (int i=0;i= 16){ try { WebSettings settings = webView.getSettings(); @@ -563,16 +589,7 @@ public void onDownloadStart(String url, String userAgent, String contentDisposit else { nextDownload=DownloadHandler.createHandler(MainActivity.this,url,userAgent,contentDisposition,mimeType,l); } - nextDownload.progress=MainActivity.this.dlProgress; - nextDownload.dlText=MainActivity.this.dlText; - download=nextDownload; - Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - intent.setType(mimeType); - if (nextDownload.fileName != null) { - intent.putExtra(Intent.EXTRA_TITLE, nextDownload.fileName); - } - startActivityForResult(intent,Constants.FILE_OPEN_DOWNLOAD); + startNextDownload(nextDownload,mimeType); }catch (Throwable t){ Toast.makeText(MainActivity.this,"download error:"+t,Toast.LENGTH_LONG).show(); } diff --git a/build.gradle b/build.gradle index b8a6a283a..855d2ac6a 100644 --- a/build.gradle +++ b/build.gradle @@ -316,7 +316,7 @@ task release{ println "all release packages have been build" } dependsOn testGit, releaseRpm, releaseDeb,raspiDeb - dependsOn windowsBuild + dependsOn windowsBuild, buildZip if (buildAndroid){ dependsOn assembleReleaseAndroid } @@ -328,7 +328,7 @@ task beta{ println "all beta packages have been build" } dependsOn releaseRpm, releaseDeb,raspiDeb - dependsOn windowsBuild + dependsOn windowsBuild, buildZip if (buildAndroid){ dependsOn assembleBetaAndroid } diff --git a/docs/en_install.html b/docs/en_install.html index 7403f3ca2..7b1f25007 100644 --- a/docs/en_install.html +++ b/docs/en_install.html @@ -306,7 +306,7 @@

Connection via the internal Wifi

There is one restriction: Unfortunately xxx.local will not work on Android devices. I recommend installing a tool that is able to resolve mDNS-  - BonjourBrowser . For IOS there is a similar + BonjourBrowser . For IOS there is a similar tool - although xxx.local should work there in the browser. You will find your Pi with the AvNav image with the name "avnav-server". Normally there will be a second entry "avnav" - this is SignalK.
diff --git a/docs/install.html b/docs/install.html index 4a3ea267d..d30eb01c8 100644 --- a/docs/install.html +++ b/docs/install.html @@ -324,7 +324,7 @@

Verbindung über das eingebaute WLAN

Eine Einschränkung bleibt: Leider funktioniert xxx.local nicht auf Android-Geräten. Daher empfehle ich, dort ein Tool zu nutzen, das mDNS nutzen kann - einen - BonjourBrowser . Für IOS gibt es ein  vergleichbares + BonjourBrowser . Für IOS gibt es ein  vergleichbares Tool - auch wenn dort der Eintrag "xxx.local" im Browser funktioniert. Man wird seinen Raspberry mit dem AvNav-Image in den Browsern unter dem Namen "avnav-server" finden. Typischerweise wird man @@ -588,7 +588,8 @@

Download

Avnav als Systemdienst starten. Wenn man diese Datei nicht anlegt/kopiert, wird AvNav nicht mit den Nutzer "pi", sondern mit dem Nutzer "avnav" arbeiten.

-

OpenPlotter

+ +

OpenPlotter

Für OpenPlotter gibt es eine komplette Integration von AvNav (Dank an e-sailing). Im Repository https://www.free-x.de/deb4op/ diff --git a/server/avnav_api.py b/server/avnav_api.py index 95a5ad022..2b23dd021 100644 --- a/server/avnav_api.py +++ b/server/avnav_api.py @@ -177,7 +177,7 @@ def addNMEA(self, nmea, addCheckSum=False,omitDecode=True,source=None): """ raise NotImplemented() - def addData(self, key, value, source=None,record=None): + def addData(self, key, value, source=None,record=None, sourcePriority=60): """ add a data item (potentially from a decoded NMEA record) to the internal data store the data added here later on will be fetched from the GUI diff --git a/server/avnav_nmea.py b/server/avnav_nmea.py index a80d2bd54..8c669d68f 100644 --- a/server/avnav_nmea.py +++ b/server/avnav_nmea.py @@ -82,6 +82,7 @@ class NMEAParser(object): K_DEPTHT=Key('depthBelowTransducer','depthBelowTransducer in m','m','environment.depth.belowTransducer') K_DEPTHW=Key('depthBelowWaterline','depthBelowWaterlinein m','m','environment.depth.belowSurface') K_DEPTHK=Key('depthBelowKeel','depthBelowKeel in m','m','environment.depth.belowKeel') + K_TIME=Key('time','the received GPS time',signalK='navigation.datetime') #we will add the GPS base to all entries GPS_DATA=[ K_LAT, @@ -98,7 +99,7 @@ class NMEAParser(object): K_DEPTHT, K_DEPTHW, K_DEPTHK, - Key('time','the received GPS time',signalK='navigation.datetime'), + K_TIME, Key('satInview', 'number of Sats in view',signalK='navigation.gnss.satellitesInView.count'), Key('satUsed', 'number of Sats in use',signalK='navigation.gnss.satellites'), Key('transducers.*','transducer data from xdr'), diff --git a/server/avnav_store.py b/server/avnav_store.py index 8a6f10516..067c64826 100644 --- a/server/avnav_store.py +++ b/server/avnav_store.py @@ -405,14 +405,29 @@ def isKeyRegistered(self,key,source=None): @param source: if not None: only return True if registered by different source @return: ''' - rt=self.__allowedKey(key) - if not rt: - return False - if source is None: - return rt - return self.__keySources[key] != source + try: + self.__checkAlreadyExists(key) + except: + #we come here if it already exists + if source is None: + return True + existingSource = self.__keySources.get(key) + return existingSource != source + return False - def registerKey(self,key,keyDescription,source=None): + def __checkAlreadyExists(self,key): + for existing in list(self.__registeredKeys.keys()): + if existing == key or key.startswith(existing): + raise Exception("key %s already registered from %s:%s" % (key,existing,self.__registeredKeys[existing])) + for existing in list(self.__wildcardKeys.keys()): + if self.wildCardMatch(key, existing): + raise Exception("key %s matches wildcard from %s:%s" % (key, existing, self.__wildcardKeys[existing])) + if self.__isWildCard(key): + for existing in list(self.__registeredKeys.keys()): + if self.wildCardMatch(existing, key): + raise Exception("wildcard key %s matches existing from %s:%s" % (key, existing, self.__registeredKeys[existing])) + + def registerKey(self,key,keyDescription,source=None,allowOverwrite=False): """ register a new key description raise an exception if there is already a key with the same name or a prefix of it @@ -424,16 +439,8 @@ def registerKey(self,key,keyDescription,source=None): if source is not None and self.__keySources.get(key) == source: AVNLog.ld("key re-registration - ignore - for %s, source %s",key,source) return - for existing in list(self.__registeredKeys.keys()): - if existing == key or key.startswith(existing): - raise Exception("key %s already registered from %s:%s" % (key,existing,self.__registeredKeys[existing])) - for existing in list(self.__wildcardKeys.keys()): - if self.wildCardMatch(key, existing): - raise Exception("key %s matches wildcard from %s:%s" % (key, existing, self.__wildcardKeys[existing])) - if self.__isWildCard(key): - for existing in list(self.__registeredKeys.keys()): - if self.wildCardMatch(existing, key): - raise Exception("wildcard key %s matches existing from %s:%s" % (key, existing, self.__registeredKeys[existing])) + if not allowOverwrite: + self.__checkAlreadyExists(key) self.__keySources[key]=source if self.__isWildCard(key): self.__wildcardKeys[key]=keyDescription diff --git a/server/handler/avndirectories.py b/server/handler/avndirectories.py index 420a388c2..f62b733d2 100644 --- a/server/handler/avndirectories.py +++ b/server/handler/avndirectories.py @@ -110,8 +110,36 @@ def __init__(self,param): AVNDirectoryHandlerBase.__init__(self, param, "overlay") self.baseDir = AVNHandlerManager.getDirWithDefault(self.param, 'overlayDir', "overlays") +class AVNIconHandler(AVNDirectoryHandlerBase): + PREFIX = "/icons" + @classmethod + def getPrefix(cls): + return cls.PREFIX + + @classmethod + def canDelete(self): + return False + + @classmethod + def canUpload(self): + return False + + @classmethod + def canDownload(self): + return False + + def __init__(self,param): + AVNDirectoryHandlerBase.__init__(self, param, "icons") + + def startInstance(self, navdata): + super().startInstance(navdata) + self.baseDir=os.path.join(self.httpServer.handlePathmapping('viewer'),'images') + + + avnav_handlerList.registerHandler(AVNOverlayHandler) avnav_handlerList.registerHandler(AVNUserHandler) avnav_handlerList.registerHandler(AVNImagesHandler) +avnav_handlerList.registerHandler(AVNIconHandler) diff --git a/server/handler/baseconfig.py b/server/handler/baseconfig.py index ad60bbd7d..0803c9487 100644 --- a/server/handler/baseconfig.py +++ b/server/handler/baseconfig.py @@ -36,6 +36,7 @@ from avnav_store import AVNStore from avnav_worker import AVNWorker, WorkerParameter, WorkerStatus from avnav_util import AVNLog, AVNUtil +from avnav_nmea import NMEAParser class TimeSource(object): @@ -199,7 +200,7 @@ def startInstance(self, navdata): def fetchGpsTime(self): try: - curGpsTime=self.navdata.getSingleValue(AVNStore.BASE_KEY_GPS + ".time",includeInfo=True) + curGpsTime=self.navdata.getSingleValue(NMEAParser.K_TIME.getKey(),includeInfo=True) if curGpsTime is None: return None,None dt=AVNUtil.gt(curGpsTime.value) @@ -275,16 +276,16 @@ def run(self): lat=None lon=None try: - lat=self.navdata.getSingleValue(AVNStore.BASE_KEY_GPS+".lat") - lon = self.navdata.getSingleValue(AVNStore.BASE_KEY_GPS + ".lon") + lat=self.navdata.getSingleValue(NMEAParser.K_LAT.getKey(),includeInfo=True) + lon = self.navdata.getSingleValue(NMEAParser.K_LON.getKey(),includeInfo=True) except Exception as e: AVNLog.error("Exception when getting curGpsData: %s",traceback.format_exc()) if ( lat is not None) and (lon is not None): #we have some position if not hasFix: - AVNLog.info("new GPS fix lat=%f lon=%f",lat,lon) + AVNLog.info("new GPS fix lat=%f[%s] lon=%f[%s]",lat.value,lat.source,lon.value,lon.source) hasFix=True - self.setInfo(self.GPSPOS_CHILD,"GPS fix lat=%f lon=%f"%(lat,lon),WorkerStatus.NMEA) + self.setInfo(self.GPSPOS_CHILD,"GPS fix lat=%f[%s] lon=%f[%s]"%(lat.value,lat.source,lon.value,lon.source),WorkerStatus.NMEA) else: self.setInfo(self.GPSPOS_CHILD,'no valid position',WorkerStatus.ERROR) if hasFix: diff --git a/server/handler/pluginhandler.py b/server/handler/pluginhandler.py index d282c2ed6..301e1152d 100644 --- a/server/handler/pluginhandler.py +++ b/server/handler/pluginhandler.py @@ -24,7 +24,7 @@ # parts from this software (AIS decoding) are taken from the gpsd project # so refer to this BSD licencse also (see ais.py) or omit ais.py ############################################################################### -import imp +import importlib.util import inspect import json from typing import Dict, Any @@ -68,7 +68,7 @@ def normalizedName(name): return name class ApiImpl(AVNApi): - def __init__(self,parent,store,queue,prefix,moduleFile): + def __init__(self,parent,store,queue,prefix,moduleFile,internal=False): """ @param parent: the pluginhandler instance to access cfg data @@ -94,6 +94,7 @@ def __init__(self,parent,store,queue,prefix,moduleFile): self.converters=set() self.settingsFiles=[] self.jsCssOnly=False + self.internal=internal def isEnabled(self): return AVNUtil.getBool(self.getConfigValue(AVNPluginHandler.ENABLE_PARAMETER.name),True) @@ -194,7 +195,10 @@ def addKey(self,data): raise Exception("%s: missing path in data entry: %s"%(self.prefix,data)) AVNLog.info("%s: register key %s"%(self.prefix,key)) if self.store.isKeyRegistered(key,keySource): - allowOverwrite=self.getConfigValue(AVNApi.ALLOW_KEY_OVERWRITE,"false") + allowOverwrite=self.getConfigValue(AVNApi.ALLOW_KEY_OVERWRITE) + if allowOverwrite is None: + #let internal plugins default to true for keyOverride + allowOverwrite='true' if self.internal else 'false' if allowOverwrite.lower() != "true": self.error("key %s already registered, skipping it"%key) if key.find('*') >= 0: @@ -204,6 +208,8 @@ def addKey(self,data): if key in self.patterns: self.patterns.remove(key) return + else: + self.store.registerKey(key,data,keySource,allowOverwrite=True) else: self.store.registerKey(key,data,keySource) if key.find('*') >= 0: @@ -491,28 +497,31 @@ def isHidden(self,name): if ev == '1': return True return False - + D_BUILTIN='builtin' + D_SYSTEM='system' + D_USER='user' + ALL_DIRS=[D_BUILTIN,D_SYSTEM,D_USER] def run(self): builtInDir=self.getStringParam('builtinDir') systemDir=AVNHandlerManager.getDirWithDefault(self.param, 'systemDir', defaultSub=os.path.join('..', 'plugins'), belowData=False) userDir=AVNHandlerManager.getDirWithDefault(self.param, 'userDir', 'plugins') directories={ - 'builtin':{ + self.D_BUILTIN:{ 'dir':builtInDir, 'prefix':'builtin' }, - 'system':{ + self.D_SYSTEM:{ 'dir':systemDir, 'prefix':'system' }, - 'user':{ + self.D_USER:{ 'dir':userDir, 'prefix':'user' } } - for basedir in ['builtin','system','user']: + for basedir in self.ALL_DIRS: dircfg=directories[basedir] if not os.path.isdir(dircfg['dir']): continue @@ -529,7 +538,7 @@ def run(self): module=self.loadPluginFromDir(dir, moduleName) except: AVNLog.error("error loading plugin from %s:%s",dir,traceback.format_exc()) - api = ApiImpl(self,self.navdata,self.queue,moduleName,inspect.getfile(module) if module is not None else module) + api = ApiImpl(self,self.navdata,self.queue,moduleName,inspect.getfile(module) if module is not None else module,internal=(basedir==self.D_BUILTIN)) self.createdApis[moduleName]=api if module is not None: self.pluginDirs[moduleName]=os.path.realpath(dir) @@ -654,9 +663,11 @@ def loadPluginFromDir(self, dir, name): if not os.path.exists(moduleFile): return None try: - rt = imp.load_source(name, moduleFile) + spec = importlib.util.spec_from_file_location(name, moduleFile) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) AVNLog.info("loaded %s as %s", moduleFile, name) - return rt + return module except: AVNLog.error("unable to load %s:%s", moduleFile, traceback.format_exc()) return None diff --git a/server/handler/udpreader.py b/server/handler/udpreader.py index fc7d0d8af..a6723ad51 100644 --- a/server/handler/udpreader.py +++ b/server/handler/udpreader.py @@ -25,6 +25,9 @@ # so refer to this BSD licencse also (see ais.py) or omit ais.py ############################################################################### import socket +import struct + +import netifaces from socketbase import * @@ -40,6 +43,11 @@ class AVNUdpReader(AVNWorker): description="the local listener port") P_MINTIME=WorkerParameter('minTime',0,type=WorkerParameter.T_FLOAT, description='wait this time before reading new data (ms)') + P_ALLOWMC=WorkerParameter('joinMulticast',0,type=WorkerParameter.T_BOOLEAN, + description='join a multicast group') + P_MCADDR=WorkerParameter('multicastAddr','224.0.0.1', type=WorkerParameter.T_STRING, + description="join this multicast group on all interfaces", + condition={P_ALLOWMC.name:True}) @classmethod def getConfigParam(cls, child=None): @@ -51,7 +59,9 @@ def getConfigParam(cls, child=None): cls.P_PORT, cls.P_MINTIME, cls.FILTER_PARAM, - SocketReader.P_STRIP_LEADING + SocketReader.P_STRIP_LEADING, + cls.P_ALLOWMC, + cls.P_MCADDR ] return rt @@ -85,6 +95,22 @@ def checkConfig(self, param): if self.P_PORT.name in param: self.checkUsedResource(UsedResource.T_UDP,self.P_PORT.fromDict(param)) + def joinGroup(self,mcgroup): + interfaces=netifaces.interfaces() + for intf in interfaces: + intfaddr=netifaces.ifaddresses(intf) + if intfaddr is not None: + ips=intfaddr.get(netifaces.AF_INET) + if ips is not None: + for ip in ips: + addr=ip.get('addr') + if addr is not None: + try: + mreq = struct.pack("4s4s", socket.inet_aton(mcgroup), socket.inet_aton(addr)) + self.socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + except: + pass + #thread run method - just try forever def run(self): for p in (self.P_PORT, self.P_LADDR): @@ -101,7 +127,12 @@ def run(self): info = "%s:%d" % (host,port) self.setInfo(INAME,"trying udp listen at %s"%(info,),WorkerStatus.INACTIVE) self.socket=socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.socket.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,True) self.socket.bind((host, port)) + if self.getWParam(self.P_ALLOWMC): + mcgroup=self.getWParam(self.P_MCADDR) + self.joinGroup(mcgroup) + info+="[%s]"%mcgroup self.setInfo(INAME,"listening at %s"%(info,),WorkerStatus.RUNNING) except: AVNLog.info("exception while trying to listen at %s:%d %s",host,port,traceback.format_exc()) @@ -122,6 +153,7 @@ def run(self): minTime=self.P_MINTIME.fromDict(self.param)) except: AVNLog.info("exception while reading data from %s:%d %s",self.getStringParam('host'),self.getIntParam('port'),traceback.format_exc()) + avnav_handlerList.registerHandler(AVNUdpReader) diff --git a/server/plugins/canboat/plugin.py b/server/plugins/canboat/plugin.py index 7dd38ac54..2474006f2 100644 --- a/server/plugins/canboat/plugin.py +++ b/server/plugins/canboat/plugin.py @@ -1,62 +1,107 @@ +# -*- coding: utf-8 -*- +# vim: ts=2 sw=2 et ai +############################################################################### +# Copyright (c) 2012,2021,2019,2024 Andreas Vogel andreas@wellenvogel.net +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# +# parts from this software (AIS decoding) are taken from the gpsd project +# so refer to this BSD licencse also (see ais.py) or omit ais.py +############################################################################### + +#----------------------------------------------------------------------------- +# Hint: +# this plugin uses a couple of internal functions of AvNav and therefore +# you should not use this as an example for own plugins +# Those internal functions can change at any point in time without notice. +#----------------------------------------------------------------------------- + import datetime import json import socket import sys +import threading import time import traceback -#the following import is optional -#it only allows "intelligent" IDEs (like PyCharm) to support you in using it from avnav_api import AVNApi +from avnav_worker import WorkerParameter +from avnav_nmea import Key,NMEAParser + +class Status: + def __init__(self,base,source): + self.base=base + self.source=source + def toState(self,api): + lat=api.getSingleValue(NMEAParser.K_LAT.getKey(),True) + lon=api.getSingleValue(NMEAParser.K_LON.getKey(),True) + tm=api.getSingleValue(NMEAParser.K_TIME.getKey(),True) + hasPos=(lat is not None and lon is not None and lat.source==self.source and lon.source==self.source ) + hasTime=(tm is not None and tm.source==self.source) + if not hasPos and not hasTime: + api.setStatus("RUNNING",self.base) + else: + txt=self.base + if hasTime: + txt+=", validTime" + if hasPos: + txt+=", validPosition" + api.setStatus("NMEA",txt) class Plugin(object): - PATH="gps.time" NM = 1852.0 #PGNS used to set the time DEFAULT_PGNS='126992,129029' + P_PORT=WorkerParameter('port',description='canbus json port',default=2598,type=WorkerParameter.T_NUMBER) + P_HOST=WorkerParameter('host',description='canbus json host (default: localhost)',default='localhost') + P_SENDRMC=WorkerParameter('autoSendRMC',description='time in seconds with no RMC to start sending it,0: off', + default=0,type= WorkerParameter.T_NUMBER) + P_SRC=WorkerParameter('sourceName',description='source name to be set for the generated records (defaults to plugin name)', + default='') + P_IV=WorkerParameter('timeInterval',description='time in seconds to store time received via n2k (also used as interval for auto send RMC, 0 to disable)', + default= 0.5,type=WorkerParameter.T_FLOAT) + P_TIPGNS=WorkerParameter('timePGNs',description='PGNs used to set time', + default=DEFAULT_PGNS) + P_READPOS=WorkerParameter('readPos',description='read position from 129025 and cog/sog from 129026', + default=True, type=WorkerParameter.T_BOOLEAN) + P_PRIORITY=WorkerParameter('priority', description='priority of for the channel', + default=40, + type=WorkerParameter.T_NUMBER) + PATHES=[ + NMEAParser.K_TIME, + NMEAParser.K_SOG, + NMEAParser.K_COG, + NMEAParser.K_LON, + NMEAParser.K_LAT + ] CONFIG=[ - { - 'name':'port', - 'description':'canbus json port', - 'default':2598, - 'type': 'NUMBER' - }, - { - 'name': 'host', - 'description': 'canbus json host (default: localhost)', - 'default': 'localhost' - }, - { - 'name': 'allowKeyOverwrite', - 'description': 'necessary to be able to set our time directly from canboat', - 'default': False, - 'type': 'BOOLEAN' - }, - { - 'name': 'autoSendRMC', - 'description': 'time in seconds with no RMC to start sending it,0: off', - 'default': 0, - 'type': 'NUMBER' - }, - { - 'name': 'sourceName', - 'description': 'source name to be set for the generated records (defaults to plugin name)', - 'default': '' - }, - { - 'name': 'timeInterval', - 'description': 'time in seconds to store time received via n2k', - 'default': 0.5, - 'type':'FLOAT' - }, - { - 'name': 'timePGNs', - 'description':'PGNs used to set time', - 'default': DEFAULT_PGNS - } + P_PORT, + P_HOST, + P_SENDRMC, + P_READPOS, + P_SRC, + P_IV, + P_TIPGNS, + P_PRIORITY ] - + CONFIGLIST=list(map(lambda v:v.__dict__,CONFIG)) @classmethod def pluginInfo(cls): """ @@ -71,13 +116,8 @@ def pluginInfo(cls): return { 'description': 'a plugin that reads some PGNS from canboat. Currently supported: 126992:SystemTime. You need to set allowKeyOverwrite=true', 'version': '1.0', - 'config': cls.CONFIG, - 'data': [ - { - 'path': cls.PATH, - 'description': 'time from pgn 126992', - } - ] + 'config': cls.CONFIGLIST, + 'data': list(map(lambda k: {'path':k.getKey(),'description':k.description },cls.PATHES)) } def __init__(self,api): @@ -90,9 +130,22 @@ def __init__(self,api): """ self.api = api # type: AVNApi self.api.registerRestart(self.stop) - self.api.registerEditableParameters(self.CONFIG,self.changeConfig) + self.api.registerEditableParameters(self.CONFIGLIST,self.changeConfig) self.changeSequence=0 self.socket=None + self.lastRmc=time.monotonic() + self.status=None + + def rmcWatcher(self,sequence, source): + while sequence == self.changeSequence: + seq=0 + seq,data=self.api.fetchFromQueue(seq,10,includeSource=True,filter='$RMC') + if len(data) > 0: + for d in data: + if d.source != source: + self.lastRmc=time.monotonic() + break + def changeConfig(self,newValues): self.api.saveConfigValues(newValues) @@ -112,7 +165,19 @@ def stop(self): def run(self): while not self.api.shouldStopMainThread(): self._runInternal() - + def _getConfig(self,cfg:WorkerParameter): + v=self.api.getConfigValue(cfg.name,cfg.default) + return cfg.checkValue(v,False) + def _getField(self,msg,name,sub=None): + fields=msg.get("fields") + if fields is None: + return + rt=fields.get(name) + if sub is None or rt is None: + return rt + if isinstance(rt,dict): + return rt.get(sub) + return rt def _runInternal(self): sequence=self.changeSequence """ @@ -125,39 +190,49 @@ def _runInternal(self): """ port=2598 sock=None - host=self.api.getConfigValue('host','localhost') + host=self._getConfig(self.P_HOST) timeInterval=0.5 try: - port=self.api.getConfigValue('port','2598') - port=int(port) - timeInterval=float(self.api.getConfigValue('timeInterval','0.5')) + port=self._getConfig(self.P_PORT) + timeInterval=self._getConfig(self.P_IV) except: self.api.log("exception while reading config values %s",traceback.format_exc()) raise - autoSendRMC=int(self.api.getConfigValue('autoSendRMC',"0")) - handledPGNs=self.api.getConfigValue('timePGNs',self.DEFAULT_PGNS).split(',') - if len(handledPGNs) < 1: + autoSendRMC=self._getConfig(self.P_SENDRMC) + handledPGNs=self._getConfig(self.P_TIPGNS).split(',') + readPos=self._getConfig(self.P_READPOS) + if len(handledPGNs) < 1 and not readPos: self.api.log("no pgns to be handled, stopping plugin") self.api.setStatus("INACTIVE", "no pgns to be handled") return handledPGNs=[int(p) for p in handledPGNs] self.api.log("started with host=%s,port %d, autoSendRMC=%d"%(host,port,autoSendRMC)) - source=self.api.getConfigValue("sourceName",None) + source=self._getConfig(self.P_SRC) + rmcWatcher=threading.Thread(target=self.rmcWatcher,args=[sequence,source],daemon=True) + rmcWatcher.start() + priority=self._getConfig(self.P_PRIORITY) errorReported=False self.api.setStatus("STARTED", "connecting to n2kd at %s:%d"%(host,port)) while sequence == self.changeSequence: try: - self.socket = socket.create_connection((host, port),timeout=1000) - self.api.setStatus("RUNNING", "connected to n2kd at %s:%d" %(host,port)) - hasNmea=False + self.socket = socket.create_connection((host, port),timeout=20) + self.status=Status("connected to n2kd at %s:%d" %(host,port),source) + self.status.toState(self.api) + self.socket.settimeout(1) buffer="" - lastTimeSet=self.api.timestampFromDateTime() - while True: - data = self.socket.recv(1024) - if len(data) == 0: - raise Exception("connection to n2kd lost") - buffer = buffer + data.decode('ascii', 'ignore') + lastTimeSet=time.monotonic() + while sequence == self.changeSequence: + try: + data = self.socket.recv(1024) + if len(data) == 0: + raise Exception("connection to n2kd lost") + buffer = buffer + data.decode('ascii', 'ignore') + except socket.timeout: + pass lines = buffer.splitlines(True) + if len(lines) == 0: + self.status.toState(self.api) + continue if lines[-1][-1] == '\n': buffer="" else: @@ -170,51 +245,64 @@ def _runInternal(self): msg=json.loads(l) errorReported=False #{"timestamp":"2016-02-28-20:32:48.226","prio":3,"src":27,"dst":255,"pgn":126992,"description":"System Time","fields":{"SID":117,"Source":"GPS","Date":"2016.02.28", "Time": "19:57:46.05000"}} - if msg.get('pgn') in handledPGNs: + pgn=msg.get('pgn') + if pgn in handledPGNs: #currently we can decode messages that have a Date and Time field set - now = self.api.timestampFromDateTime() - if now < lastTimeSet or now > (lastTimeSet+timeInterval): - fields = msg.get('fields') - if fields is not None: - cdate = fields.get('Date') - ctime = fields.get('Time') - dt=None - if cdate is not None and ctime is not None: - tsplit = ctime.split(".") - dt = datetime.datetime.strptime(cdate + " " + tsplit[0], "%Y.%m.%d %H:%M:%S") - if len(tsplit) > 1: - dt += datetime.timedelta(seconds=float("0." + tsplit[1])) - if dt is not None: - if not hasNmea: - self.api.log("received time %s"%dt.isoformat()) - self.api.setStatus("NMEA", "valid time") - hasNmea=True - self.api.addData(self.PATH,self.formatTime(dt)) - lastTimeSet=now - if autoSendRMC > 0: - lastRmc=self.api.getSingleValue("internal.last.RMC",includeInfo=True) - if lastRmc is None or lastRmc.timestamp < (now - autoSendRMC) or (lastRmc.timestamp > now) : - lat=self.api.getSingleValue("gps.lat") - lon=self.api.getSingleValue("gps.lon") - if lat is not None and lon is not None: - speed=self.api.getSingleValue("gps.speed") - cog=self.api.getSingleValue("gps.track") - self.api.debug("generating RMC lat=%f,lon=%f,ts=%s",lat,lon,dt.isoformat()) - # $--RMC,hhmmss.ss,A,llll.ll,a,yyyyy.yy,a,x.x,x.x,xxxx,x.x,a*hh - fixutc="%02d%02d%02d.%02d"%(dt.hour,dt.minute,dt.second,dt.microsecond/1000) - (latstr,NS)=self.nmeaFloatToPos(lat,True) - (lonstr,EW)=self.nmeaFloatToPos(lon,False) - speedstr="" if speed is None else "%.2f"%(speed*3600/self.NM) - year="%04d"%dt.year - datestr="%02d%02d%s"%(dt.day,dt.month,year[-2:]) - cogstr="" if cog is None else "%.2f"%cog - record="$GPRMC,%s,A,%s,%s,%s,%s,%s,%s,%s,,,A"%(fixutc,latstr,NS,lonstr,EW,speedstr,cogstr,datestr) - self.api.addNMEA(record,addCheckSum=True,source=source) + now = time.monotonic() + if now >= (lastTimeSet + timeInterval) and timeInterval > 0: + lastTimeSet=now + cdate=self._getField(msg,'Date','value') + ctime=self._getField(msg,'Time','value') + dt=None + if cdate is not None and ctime is not None: + dt=datetime.datetime(year=1970,month=1,day=1) + dt+=datetime.timedelta(days=cdate,milliseconds=ctime/10) + if dt is not None: + self.api.addData(NMEAParser.K_TIME.getKey(), self.formatTime(dt),source=source, sourcePriority=priority) + if autoSendRMC > 0: + if self.lastRmc is None or self.lastRmc < (now - autoSendRMC): + lat=self.api.getSingleValue("gps.lat") + lon=self.api.getSingleValue("gps.lon") + if lat is not None and lon is not None: + speed=self.api.getSingleValue("gps.speed") + cog=self.api.getSingleValue("gps.track") + self.api.debug("generating RMC lat=%f,lon=%f,ts=%s",lat,lon,dt.isoformat()) + # $--RMC,hhmmss.ss,A,llll.ll,a,yyyyy.yy,a,x.x,x.x,xxxx,x.x,a*hh + fixutc="%02d%02d%02d.%02d"%(dt.hour,dt.minute,dt.second,dt.microsecond/1000) + (latstr,NS)=self.nmeaFloatToPos(lat,True) + (lonstr,EW)=self.nmeaFloatToPos(lon,False) + speedstr="" if speed is None else "%.2f"%(speed*3600/self.NM) + year="%04d"%dt.year + datestr="%02d%02d%s"%(dt.day,dt.month,year[-2:]) + cogstr="" if cog is None else "%.2f"%cog + record="$GPRMC,%s,A,%s,%s,%s,%s,%s,%s,%s,,,A"%(fixutc,latstr,NS,lonstr,EW,speedstr,cogstr,datestr) + self.api.addNMEA(record,addCheckSum=True, source=source) + if readPos: + if pgn == 129025: #pos + clat=self.api.getSingleValue(NMEAParser.K_LAT.getKey(),includeInfo=True) + clon=self.api.getSingleValue(NMEAParser.K_LON.getKey(),includeInfo=True) + if clon is None or clon.source == source or clat is None or clat.source == source: + lon=self._getField(msg,'Longitude') + lat=self._getField(msg,'Latitude') + if lon is not None and lat is not None: + self.api.addData(NMEAParser.K_LON.getKey(),lon,source=source,sourcePriority=priority) + self.api.addData(NMEAParser.K_LAT.getKey(),lat,source=source,sourcePriority=priority) + if pgn == 129026: #sog/cog + csog=self.api.getSingleValue(NMEAParser.K_SOG.getKey(),includeInfo=True) + ccog=self.api.getSingleValue(NMEAParser.K_COG.getKey(),includeInfo=True) + if csog is None or csog.source == source or ccog is None or ccog.source == source: + ref=self._getField(msg,"COG Reference") + if ref is not None and ref.get('value') == 0: + cog=self._getField(msg,'COG') + sog=self._getField(msg,'SOG') + if sog is not None and cog is not None: + self.api.addData(NMEAParser.K_SOG.getKey(),sog,source=source,sourcePriority=priority) + self.api.addData(NMEAParser.K_COG.getKey(),cog,source=source,sourcePriority=priority) #add other decoders here except: self.api.log("unable to decode json %s:%s"%(l,traceback.format_exc())) - pass + self.status.toState(self.api) if len(buffer) > 4096: raise Exception("no line feed in long data, stopping") except Exception as e: diff --git a/server/test/test_decoder.py b/server/test/test_decoder.py index 5c13c7d0e..3ebf4a2d1 100644 --- a/server/test/test_decoder.py +++ b/server/test/test_decoder.py @@ -44,14 +44,19 @@ def addData(self,path,value): return print("@@DATA@@:%s->%s"%(path,value)) -import os, glob, imp +import os, glob, importlib.util +def loadModule(name,path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module def loadModulesFromDir(dir,logger,prefix=''): modules = {} for path in glob.glob(os.path.join(dir, '[!_]*.py')): name, ext = os.path.splitext(os.path.basename(path)) try: - modules[prefix+name] = imp.load_source(prefix + name, path) + modules[prefix+name] = loadModule(prefix + name, path) logger.log("loaded %s as %s",path, prefix+name) except: logger.error("unable to load %s:%s",path,traceback.format_exc()) diff --git a/viewer/App.jsx b/viewer/App.jsx index 1355b3b9c..46d8f6991 100644 --- a/viewer/App.jsx +++ b/viewer/App.jsx @@ -1,9 +1,9 @@ //avnav (C) wellenvogel 2019 -import React, { Component } from 'react'; +import React, {Component, createRef, useEffect} from 'react'; import History from './util/history.js'; import Dynamic from './hoc/Dynamic.jsx'; -import keys,{KeyHelper} from './util/keys.jsx'; +import keys from './util/keys.jsx'; import MainPage from './gui/MainPage.jsx'; import InfoPage from './gui/InfoPage.jsx'; import GpsPage from './gui/GpsPage.jsx'; @@ -22,14 +22,18 @@ import WarningPage from './gui/WarningPage.jsx'; import ViewPage from './gui/ViewPage.jsx'; import AddonConfigPage from './gui/AddOnConfigPage.jsx'; import ImporterPage from "./gui/ImporterPage"; -import OverlayDialog from './components/OverlayDialog.jsx'; +import OverlayDialog, { + DialogContext, + GlobalDialogDisplay, + setGlobalContext, + useDialog +} from './components/OverlayDialog.jsx'; import globalStore from './util/globalstore.jsx'; import Requests from './util/requests.js'; import SoundHandler from './components/SoundHandler.jsx'; import Toast,{ToastDisplay} from './components/Toast.jsx'; import KeyHandler from './util/keyhandler.js'; import LayoutHandler from './util/layouthandler.js'; -import assign from 'object-assign'; import AlarmHandler, {LOCAL_TYPES} from './nav/alarmhandler.js'; import GuiHelpers, {stateHelper} from './util/GuiHelpers.js'; import Mob from './components/Mob.js'; @@ -44,11 +48,12 @@ import propertyHandler from "./util/propertyhandler"; import MapHolder from "./map/mapholder"; import NavData from './nav/navdata'; import alarmhandler from "./nav/alarmhandler.js"; -import LocalStorage, {PREFIX_NAMES, STORAGE_NAMES} from './util/localStorageManager'; +import LocalStorage, {STORAGE_NAMES} from './util/localStorageManager'; import splitsupport from "./util/splitsupport" import leavehandler from "./util/leavehandler"; //triggers querySplitMode import fullscreen from "./components/Fullscreen"; import mapholder from "./map/mapholder"; +import 'drag-drop-touch'; const DynamicSound=Dynamic(SoundHandler); @@ -90,6 +95,8 @@ class MainWrapper extends React.Component{ this.props.history.reset(); //reset history if we reach the mainpage } } +MainWrapper.propTypes=MainPage.propTypes; + const pages={ mainpage: MainWrapper, infopage: InfoPage, @@ -160,7 +167,47 @@ const ButtonSizer=(props)=>{ let lastError={ }; +let mainShowDialog=undefined; + +const GlobalDialog=()=>{ + const [DialogDisplay, setDialog] = useDialog(); + setGlobalContext(undefined,setDialog); + mainShowDialog=setDialog; + useEffect(() => { + return ()=>{ + setGlobalContext(); + mainShowDialog=undefined; + } + }, []); + return +} + +const MainBody = ({location, options, history, nightMode}) => { + + return ( + + + + + ) +}; + class App extends React.Component { + appRef=createRef(); constructor(props) { super(props); this.checkSizes=this.checkSizes.bind(this); @@ -387,8 +434,8 @@ class App extends React.Component { } checkSizes(){ if (globalStore.getData(keys.gui.global.hasActiveInputs,false)) return; - if (! this.refs.app) return; - let current=this.refs.app.getBoundingClientRect(); + if (! this.appRef.current) return; + let current=this.appRef.current.getBoundingClientRect(); if (! current) return; let small = current.width - } - const Dialogs = OverlayDialog.getDialogContainer; + }; let appClass="app"; let layoutClass=(this.props.layoutName||"").replace(/[^0-9a-zA-Z]/g,'_'); appClass+=" "+layoutClass; if (this.props.smallDisplay) appClass+=" smallDisplay"; + if (this.props.nightMode) appClass+=" nightMode"; let location=this.leftHistoryState.getValue('location'); if (location !== "warningpage") { if (! this.titleSet) { @@ -486,25 +533,16 @@ class App extends React.Component { } return

- - { ! (avnav.android || globalStore.getData(keys.gui.global.preventAlarms)) && globalStore.getData(keys.properties.localAlarmSound) ?{ - lateLoads.push(script); - }) - } + const loadScripts=(loadList)=>{ let fileref=undefined; for (let i in loadList) { @@ -154,9 +147,17 @@ export default function() { document.getElementsByTagName("head")[0].appendChild(fileref) } }; + let addScripts="addScripts"; + if (getParam(addScripts)){ + let addList=[]; + getParam(addScripts).split(',').forEach((script)=>{ + addList.push(script); + }) + loadScripts(addList); + } const doLateLoads=(loadPlugins)=>{ - ReactDOM.render(,document.getElementById('new_pages')); + createRoot(document.getElementById('new_pages')).render(); //ios browser sometimes has issues with less... setTimeout(function(){ propertyHandler.incrementSequence(); diff --git a/viewer/components/ActiveRouteWidget.jsx b/viewer/components/ActiveRouteWidget.jsx index 5ad3e61ca..a888bdd99 100644 --- a/viewer/components/ActiveRouteWidget.jsx +++ b/viewer/components/ActiveRouteWidget.jsx @@ -3,67 +3,46 @@ */ import React from "react"; -import compare from '../util/compare'; import PropTypes from 'prop-types'; import keys from '../util/keys.jsx'; import Formatter from '../util/formatter.js'; -import Helper from '../util/helper.js'; -import GuiHelper from '../util/GuiHelpers.js'; +import {WidgetFrame, WidgetProps} from "./WidgetBase"; - -class ActiveRouteWidget extends React.Component{ - constructor(props){ - super(props); - GuiHelper.nameKeyEventHandler(this,"widget"); - } - shouldComponentUpdate(nextProps,nextState){ - return Helper.compareProperties(this.props,nextProps,ActiveRouteWidget.storeKeys); - } - componentDidUpdate(){ - - } - - render() { - if (!this.props.routeName && ! this.props.isEditing) return null; - let self = this; - let classes = "widget activeRouteWidget " + this.props.className || ""; - if (this.props.isApproaching) classes += " approach "; +const ActiveRouteWidget =(props)=>{ + if (!props.routeName && ! props.isEditing) return null; + let classes = "activeRouteWidget"; + if (props.isApproaching) classes += " approach "; return ( -
-
RTE
+
-
{this.props.routeName}
+
{props.routeName}
- {Formatter.formatDistance(this.props.remain)} + {Formatter.formatDistance(props.remain)} nm
-
{Formatter.formatTime(this.props.eta)}
- { this.props.isApproaching ? +
{Formatter.formatTime(props.eta)}
+ { props.isApproaching ?
{Formatter.formatDirection(this.props.nextCourse)} + className="routeNextCourse">{Formatter.formatDirection(props.nextCourse)} °
:
}
-
+ ); } -} ActiveRouteWidget.propTypes={ - //formatter: React.PropTypes.func, - onClick: PropTypes.func, - className: PropTypes.string, - updateCallback: PropTypes.func, + ...WidgetProps, isAproaching: PropTypes.bool, routeName: PropTypes.string, eta: PropTypes.objectOf(Date), remain: PropTypes.number, - nextCourse: PropTypes.number - + nextCourse: PropTypes.number, + isEditing: PropTypes.bool }; ActiveRouteWidget.storeKeys={ isApproaching: keys.nav.route.isApproaching, diff --git a/viewer/components/Addons.js b/viewer/components/Addons.js index 7bebef9d0..4614eb13f 100644 --- a/viewer/components/Addons.js +++ b/viewer/components/Addons.js @@ -30,15 +30,18 @@ const readAddOns = function (opt_showToast,opt_includeInvalid) { }); }; -const findAddonByUrl=(addons,url)=>{ +const findAddonByUrl=(addons,url,opt_all)=>{ if (! addons || !(addons instanceof Array)) return; if (! url) return; + let rtall=[]; for (let i in addons){ let addon=addons[i]; if (addon.url == url){ - return addon; + if (! opt_all) return addon; + rtall.push(addon); } } + return opt_all?rtall:undefined; }; /** * update/add an addon diff --git a/viewer/components/AisTargetWidget.jsx b/viewer/components/AisTargetWidget.jsx index c8f20db36..30052c5d9 100644 --- a/viewer/components/AisTargetWidget.jsx +++ b/viewer/components/AisTargetWidget.jsx @@ -4,101 +4,79 @@ import React from "react"; import PropTypes from 'prop-types'; -import compare from '../util/compare'; import keys from '../util/keys.jsx'; -import Formatter from '../util/formatter.js'; import PropertyHandler from '../util/propertyhandler.js'; import AisFormatter from '../nav/aisformatter.jsx'; -import assign from 'object-assign'; -import GuiHelper from '../util/GuiHelpers.js'; +import {WidgetFrame, WidgetProps} from "./WidgetBase"; -class AisTargetWidget extends React.Component{ - constructor(props){ - super(props); - this.click=this.click.bind(this); - GuiHelper.nameKeyEventHandler(this,"widget",this.click); - } - shouldComponentUpdate(nextProps,nextState){ - return ! compare(this.props.current,nextProps.current); +const AisTargetWidget = (props) => { + const click = (ev) => { + if (ev.stopPropagation) ev.stopPropagation(); + props.onClick({...props, mmsi: props.current ? props.current.mmsi : undefined}); } - componentDidUpdate(){ + let current = props.current || {}; + let small = (props.mode === "horizontal"); + let aisProperties = {}; + let color = undefined; + if (current.mmsi && current.mmsi !== "") { + aisProperties.warning = current.warning || false; + aisProperties.nearest = current.nearest || false; + aisProperties.tracking = (current.mmsi === props.trackedMmsi); + color = PropertyHandler.getAisColor(aisProperties); } - render(){ - let current=this.props.current||{}; - let self=this; - let classes="widget aisTargetWidget "+this.props.className||""; - let small = (this.props.mode === "horizontal" ); - let aisProperties={}; - let color=undefined; - if (current.mmsi && current.mmsi !== "") { - aisProperties.warning = current.warning || false; - aisProperties.nearest = current.nearest || false; - aisProperties.tracking = (current.mmsi === this.props.trackedMmsi); - color=PropertyHandler.getAisColor(aisProperties); - } - let front=AisFormatter.format('passFront',current); - if (current.mmsi !== undefined || this.props.mode === "gps" || this.props.isEditing) { - let style=assign({},this.props.style,{backgroundColor:color}); - return ( - -
-
AIS
-
- { !small &&
- D - {AisFormatter.format('distance', current)} - nm -
} - { !small &&
- C - {AisFormatter.format('cpa', current)} - nm -
} -
-
- {current.mmsi !== undefined && + let front = AisFormatter.format('passFront', current); + if (current.mmsi !== undefined || props.mode === "gps" || props.isEditing) { + const style = {...props.style, backgroundColor: color}; + return ( + +
+ {!small &&
+ D + {AisFormatter.format('distance', current)} + nm +
} + {!small &&
+ C + {AisFormatter.format('cpa', current)} + nm +
} +
+
+ {current.mmsi !== undefined &&
T {AisFormatter.format('tcpa', current)} h
- } - {current.mmsi !== undefined && + } + {current.mmsi !== undefined &&
{front}
- } -
+ }
- ); - } - else{ - return null; - } - - } - click(ev){ - if (ev.stopPropagation) ev.stopPropagation(); - this.props.onClick(assign({},this.props,{mmsi:this.props.current?this.props.current.mmsi:undefined})); + + ); + } else { + return null; } } -AisTargetWidget.storeKeys={ +AisTargetWidget.storeKeys = { current: keys.nav.ais.nearest, isEditing: keys.gui.global.layoutEditing, trackedMmsi: keys.nav.ais.trackedMmsi }; -AisTargetWidget.propTypes={ - //formatter: React.PropTypes.func, - onClick: PropTypes.func, - className: PropTypes.string, +AisTargetWidget.propTypes = { + ...WidgetProps, + isEditing: PropTypes.bool, current: PropTypes.object, - mode: PropTypes.string + trackedMmsi: PropTypes.string }; export default AisTargetWidget; \ No newline at end of file diff --git a/viewer/components/AlarmWidget.jsx b/viewer/components/AlarmWidget.jsx index f66a7eb88..c38a26c11 100644 --- a/viewer/components/AlarmWidget.jsx +++ b/viewer/components/AlarmWidget.jsx @@ -5,76 +5,69 @@ import React from "react"; import PropTypes from 'prop-types'; import keys from '../util/keys.jsx'; -import compare from '../util/compare.js'; -import GuiHelper from '../util/GuiHelpers.js'; +import {useKeyEventHandler} from '../util/GuiHelpers.js'; import AlarmHandler from '../nav/alarmhandler.js'; +import {WidgetFrame} from "./WidgetBase"; -class AlarmWidget extends React.Component{ - constructor(props){ - super(props); - this.onClick=this.onClick.bind(this); - let self=this; - GuiHelper.keyEventHandler(this,(component,action)=>{ - if (action == 'stop'){ - if (self.props.onClick) self.props.onClick(); - } - },"alarm",["stop"]) +//TODO: compare alarm info correctly +const AlarmWidget = (props) => { + useKeyEventHandler({name: 'stop'}, "alarm", () => { + if (props.onClick) props.onClick(); + }) + const onClick = (ev) => { + if (props.onClick) { + props.onClick(ev); + } + ev.stopPropagation(); } - componentDidMount(){ - + if (props.disabled) return null; + let alarmText = undefined; + if (props.alarmInfo) { + let list = AlarmHandler.sortedActiveAlarms(props.alarmInfo) + list.forEach((al) => { + if (alarmText) { + alarmText += "," + al.name; + } else { + alarmText = al.name; + } + }) } - shouldComponentUpdate(nextProps,nextState){ - return ! AlarmHandler.compareAlarms(nextProps.alarmInfo,this.props.alarmInfo); + if (! alarmText){ + if (! props.isEditing || ! props.mode) return null; } - render(){ - if (this.props.disabled) return null; - let classes="widget alarmWidget "+this.props.className||""; - let alarmText=undefined; - if (this.props.alarmInfo){ - let list=AlarmHandler.sortedActiveAlarms(this.props.alarmInfo) - list.forEach((al)=>{ - if (alarmText){ - alarmText+=","+al.name; - } - else { - alarmText=al.name; - } - }) - } - if (! alarmText) { - if (! this.props.isEditing || ! this.props.mode) return null; - return
-
Alarm
-
; - } - return ( -
-
Alarm
-
- {alarmText} -
+ const Content = () => { + if (!alarmText) return null; + return
+ {alarmText}
- ); - } - onClick(ev){ - if (this.props.onClick){ - this.props.onClick(ev); - } - ev.stopPropagation(); } - + return ( + + + + ); } -AlarmWidget.propTypes={ + +AlarmWidget.propTypes = { className: PropTypes.string, onClick: PropTypes.func, alarmInfo: PropTypes.object, isEditing: PropTypes.bool, - style: PropTypes.object + style: PropTypes.object, + dragId: PropTypes.string, + disabled: PropTypes.bool, + mode: PropTypes.string }; -AlarmWidget.storeKeys={ +AlarmWidget.storeKeys = { alarmInfo: keys.nav.alarms.all, isEditing: keys.gui.global.layoutEditing, disabled: keys.gui.global.preventAlarms diff --git a/viewer/components/AnchorWatchDialog.jsx b/viewer/components/AnchorWatchDialog.jsx index 77bf0b231..a9bec701c 100644 --- a/viewer/components/AnchorWatchDialog.jsx +++ b/viewer/components/AnchorWatchDialog.jsx @@ -1,6 +1,11 @@ -import React from 'react'; +import React, {useState} from 'react'; import NavData from '../nav/navdata.js'; -import OverlayDialog from '../components/OverlayDialog.jsx'; +import OverlayDialog, { + DialogButtons, + DialogFrame, + showPromiseDialog, + useDialogContext +} from '../components/OverlayDialog.jsx'; import globalStore from '../util/globalstore.jsx'; import keys from '../util/keys.jsx'; import Toast from '../components/Toast.jsx'; @@ -8,14 +13,13 @@ import AlarmHandler from '../nav/alarmhandler.js'; import RouteEdit from '../nav/routeeditor.js'; import {Input, InputReadOnly} from "./Inputs"; import DialogButton from "./DialogButton"; -import assign from "object-assign"; import MapHolder from '../map/mapholder'; import NavCompute from "../nav/navcompute"; const activeRoute=new RouteEdit(RouteEdit.MODES.ACTIVE,true); -export const stopAnchorWithConfirm=(opt_resolveOnInact)=>{ +export const stopAnchorWithConfirm=(opt_resolveOnInact,opt_dialogContext)=>{ return new Promise((resolve,reject)=>{ if (activeRoute.anchorWatch() === undefined) { if (opt_resolveOnInact){ @@ -26,24 +30,18 @@ export const stopAnchorWithConfirm=(opt_resolveOnInact)=>{ } return; } - OverlayDialog.confirm("Really stop the anchor watch?") + showPromiseDialog(opt_dialogContext,OverlayDialog.createConfirmDialog("Really stop the anchor watch?")) .then(() => resolve(true)) .catch((e)=>reject(e)); }) } -class WatchDialog extends React.Component{ - constructor(props) { - super(props); - let defDistance = globalStore.getData(keys.properties.anchorWatchDefault); - this.state={ - radius: defDistance, - bearing: 0, - distance: 0, - refPoint: undefined - } - } - computeRefPoint(sv,fromCenter){ - let cv=assign({},sv); +const WatchDialog=(props)=> { + const [radius,setRadius]=useState(globalStore.getData(keys.properties.anchorWatchDefault)); + const [bearing,setBearing]=useState(0); + const [distance,setDistance]=useState(0); + const dialogContext=useDialogContext(); + const computeRefPoint=(sv,fromCenter)=>{ + let cv={radius,bearing,distance,refPoint}; if (fromCenter){ cv.refPoint=MapHolder.getCenter(); } @@ -57,29 +55,27 @@ class WatchDialog extends React.Component{ } return cv; } - render(){ - let title=this.props.active?"Update Anchor Watch":"Start Anchor Watch"; - let hasPosition=this.props.position !== undefined; - return
-

{title}

+ let title=props.active?"Update Anchor Watch":"Start Anchor Watch"; + let hasPosition=props.position !== undefined; + return {hasPosition && this.setState({radius: parseFloat(v)})} + value={radius} + onChange={(v) => setRadius(parseFloat(v))} label="Radius(m)" /> this.setState({distance: parseFloat(v)})} + value={distance} + onChange={(v) => setDistance(parseFloat(v))} label="Distance(m)" /> this.setState({bearing: parseFloat(v)})} + value={bearing} + onChange={(v) => setBearing(parseFloat(v))} label="Bearing(°)" /> } @@ -87,25 +83,24 @@ class WatchDialog extends React.Component{ label="No Position" dialogRow={true} />} - < div className="dialogButtons"> + < DialogButtons> {hasPosition && { - this.props.closeCallback(); - this.props.setCallback(this.computeRefPoint(this.state,false)); + props.setCallback(computeRefPoint(false)); }}>Boat { - this.props.closeCallback(); - this.props.setCallback(this.computeRefPoint(this.state,true)); + props.setCallback(computeRefPoint(true)); }}>Center } - {this.props.active && { - stopAnchorWithConfirm() + stopAnchorWithConfirm(undefined,dialogContext) .then(() => { - this.props.stopCallback(); - this.props.closeCallback(); + props.stopCallback(); + dialogContext.closeDialog(); }) .catch(() => { }) @@ -113,14 +108,12 @@ class WatchDialog extends React.Component{ }}>Stop } this.props.closeCallback()} >Cancel -
-
- } + + } -export const anchorWatchDialog = (overlayContainer)=> { +export const anchorWatchDialog = (opt_dialogContext)=> { let router = NavData.getRoutingHandler(); let pos = NavData.getCurrentPosition(); let isActive=false; @@ -131,7 +124,8 @@ export const anchorWatchDialog = (overlayContainer)=> { Toast("no gps position"); return; } - OverlayDialog.dialog((props)=>{ + const show=opt_dialogContext?opt_dialogContext.showDialog: OverlayDialog.dialog; + show((props)=>{ return { } return state.watchDistance !== undefined; }; -export default (opt_hide)=>{ +export default (opt_hide,opt_dialogContext)=>{ return{ name: "AnchorWatch", storeKeys: AnchorWatchKeys, @@ -175,7 +169,7 @@ export default (opt_hide)=>{ return rt; }, onClick: ()=>{ - anchorWatchDialog(undefined); + anchorWatchDialog(opt_dialogContext); }, editDisable:true } diff --git a/viewer/components/CanvasGauges.jsx b/viewer/components/CanvasGauges.jsx index 3fa722386..c5fca1ca8 100644 --- a/viewer/components/CanvasGauges.jsx +++ b/viewer/components/CanvasGauges.jsx @@ -2,14 +2,12 @@ * Created by andreas on 23.02.16. */ -import React from "react"; +import React, {useEffect, useRef} from "react"; import PropTypes from 'prop-types'; -import Helper from '../util/helper.js'; -import Value from './Value.jsx'; -import GuiHelper from '../util/GuiHelpers.js'; import {RadialGauge,LinearGauge} from 'canvas-gauges'; import base from '../base.js'; import assign from 'object-assign'; +import {WidgetFrame, WidgetProps} from "./WidgetBase"; export const getTicks=(minValue,maxValue,number)=>{ if (minValue === undefined || maxValue === undefined || number === undefined) return; @@ -23,7 +21,7 @@ export const getTicks=(minValue,maxValue,number)=>{ } //refer to https://canvas-gauges.com/documentation/user-guide/configuration const defaultTranslateFunction=(props)=>{ - let rt=props; + let rt={...props}; let defaultColors=props.nightMode?nightColors:normalColors; if (! rt.colorText) rt.colorText=defaultColors.text; let textColorNames=['colorTitle','colorUnits','colorNumbers','colorStrokeTicks','colorMajorTicks','colorMinorTicks','colorValueText']; @@ -51,68 +49,44 @@ const nightColors={ needle: 'rgba(252, 11, 11, 0.6)' } -class Gauge extends React.Component{ - constructor(props){ - super(props); - this.canvasRef=this.canvasRef.bind(this); - this.renderCanvas=this.renderCanvas.bind(this); - GuiHelper.nameKeyEventHandler(this,"widget"); - this.gauge=undefined; +const getProps=(props)=>{ + let rt=props.translateFunction?defaultTranslateFunction({...props,...props.translateFunction({...props})}):defaultTranslateFunction(props); + for (let k in rt){ + if (rt[k] === undefined) delete rt[k]; } - getProps(){ - let props=assign({},this.props); - let rt=this.props.translateFunction?defaultTranslateFunction(this.props.translateFunction(props)):defaultTranslateFunction(props); - for (let k in rt){ - if (rt[k] === undefined) delete rt[k]; - } - if (props.minValue !== undefined) props.minValue=parseFloat(props.minValue); - if (props.maxValue !== undefined) props.maxValue=parseFloat(props.maxValue); - return rt; - } - render(){ - let props=this.getProps(); - let defaultColors=props.nightMode?nightColors:normalColors; - let classes="widget canvasGauge"; - if (props.className) classes+=" "+props.className; - if (props.typeClass) classes+=" "+props.typeClass; - let style=props.style||{}; - let value=props.value; - if (typeof (this.props.formatter) === 'function'){ - value=this.props.formatter(value); + if (rt.minValue !== undefined) rt.minValue=parseFloat(rt.minValue); + if (rt.maxValue !== undefined) rt.maxValue=parseFloat(rt.maxValue); + return rt; +} + +const Gauge =(rprops)=>{ + let canvas = useRef(null); + let gauge = useRef(undefined); + useEffect(()=>{ + return ()=>{ + if (gauge.current) gauge.current.destroy(); } - let textColor=props.colorText?props.colorText:defaultColors.text; - let textStyle={color:textColor}; - return ( -
-
- {props.drawValue? -
{value}
:null} - -
- {(props.caption !== undefined )?
{props.caption}
:null} - {(props.unit !== undefined)? -
{props.unit}
- :null} -
- ); - } - componentDidUpdate(){ - this.renderCanvas(); + },[]) + let frame = useRef(null); + let value=useRef(null); + const props=getProps(rprops); + let nvalue=props.value; + if (typeof (props.formatter) === 'function'){ + nvalue=props.formatter(nvalue); } - canvasRef(item){ - this.canvas=item; - setTimeout(this.renderCanvas,0); - } - renderCanvas(){ - if (! this.canvas) return; + nvalue=parseFloat(nvalue); + if (nvalue < props.minValue) nvalue=props.minValue; + if (nvalue > props.maxValue) nvalue=props.maxValue; + const renderCanvas=()=>{ + if (!canvas.current) return; let rect=undefined; - if (this.refs.frame){ - rect=this.refs.frame.getBoundingClientRect(); + if (frame.current){ + rect=frame.current.getBoundingClientRect(); } else { - rect = this.canvas.getBoundingClientRect(); + rect = canvas.current.getBoundingClientRect(); } - let props=this.getProps(); + let props=getProps(rprops); if (props.minValue === undefined) return; if (props.maxValue === undefined) return; let makeSquare=(props.makeSquare === undefined) || props.makeSquare; @@ -123,48 +97,64 @@ class Gauge extends React.Component{ width=wh; height=wh; } - if (this.refs.value){ + if (value.current){ try { let factor=parseFloat(props.valueFontFactor||12); - let fs = parseFloat(window.getComputedStyle(this.refs.value).fontSize); + let fs = parseFloat(window.getComputedStyle(value.current).fontSize); if (fs != wh/factor) { - this.refs.value.style.fontSize=(wh/factor)+"px"; + value.current.style.fontSize=(wh/factor)+"px"; } else{ - this.refs.value.style.fontSize=undefined; + value.current.style.fontSize=undefined; } }catch(e){} } - let value=props.value; - if (typeof (props.formatter) === 'function'){ - value=props.formatter(value); - } - value=parseFloat(value); - if (value < props.minValue) value=props.minValue; - if (value > props.maxValue) value=props.maxValue; - if (! this.gauge){ + if (!gauge.current){ try { - let options = assign({}, props, {renderTo: this.canvas,width:width,height:height,value:value}); - this.gauge = new this.props.gauge(options).draw(); + let options = {...props, renderTo: canvas.current,width:width,height:height,value:nvalue}; + gauge.current = new props.gauge(options).draw(); return; }catch(e){ base.log("gauge error:"+e); } } - if (! this.gauge) return; - this.gauge.value=value; - this.gauge.update(assign({},props,{width:width,height:height,value:value})); + if (! gauge.current) return; + gauge.current.value=nvalue; + gauge.current.update({...props,width:width,height:height,value:nvalue}); + } + const canvasRef = (item) => { + if (item ) { + if (item !== canvas.current) { + if (gauge.current) gauge.current.destroy(); + gauge.current=undefined; + } + canvas.current = item; + setTimeout(renderCanvas, 0); + } } + let defaultColors=props.nightMode?nightColors:normalColors; + let classes="canvasGauge"; + if (props.typeClass) classes+=" "+props.typeClass; + let style=props.style||{}; + let textColor=props.colorText?props.colorText:defaultColors.text; + let textStyle={color:textColor}; + return ( + +
+ {props.drawValue? +
{nvalue}
:null} + +
+
+ ); }; Gauge.propTypes={ + ...WidgetProps, gauge: PropTypes.oneOfType([PropTypes.object,PropTypes.func]).isRequired, name: PropTypes.string, unit: PropTypes.string, - caption: PropTypes.string, - onClick: PropTypes.func, - className: PropTypes.string, typeClass: PropTypes.string, style: PropTypes.object, default: PropTypes.string, @@ -174,7 +164,9 @@ Gauge.propTypes={ formatter: PropTypes.oneOfType([PropTypes.string,PropTypes.func]), formatterParameters: PropTypes.array, translateFunction: PropTypes.func, //if set: a function to translate options - valueFontFactor: PropTypes.number + valueFontFactor: PropTypes.number, + minValue: PropTypes.number, + maxValue: PropTypes.number //all the options from canvas-gauges, see //https://canvas-gauges.com/documentation/user-guide/configuration }; diff --git a/viewer/components/CenterDisplayWidget.jsx b/viewer/components/CenterDisplayWidget.jsx index d0058ddce..86b06fb03 100644 --- a/viewer/components/CenterDisplayWidget.jsx +++ b/viewer/components/CenterDisplayWidget.jsx @@ -6,33 +6,23 @@ import React from "react"; import PropTypes from 'prop-types'; import keys from '../util/keys.jsx'; import Formatter from '../util/formatter.js'; -import Helper from '../util/helper.js'; -import GuiHelper from '../util/GuiHelpers.js'; +import {useKeyEventHandler} from '../util/GuiHelpers.js'; import NavCompute from "../nav/navcompute"; +import {useAvNavSortable} from "../hoc/Sortable"; +import {WidgetFrame, WidgetHead, WidgetProps} from "./WidgetBase"; -class CenterDisplayWidget extends React.Component{ - constructor(props){ - super(props); - GuiHelper.nameKeyEventHandler(this,"widget"); +const CenterDisplayWidget = (props) => { + let small = (props.mode == "horizontal"); + let measurePosition = props.measurePosition; + let measureValues; + if (measurePosition) { + measureValues = NavCompute.computeDistance(measurePosition, props.centerPosition, props.measureRhumbLine); } - shouldComponentUpdate(nextProps,nextState) { - return Helper.compareProperties(this.props,nextProps,CenterDisplayWidget.storeKeys); - } - - render() { - let classes = "widget centerDisplayWidget " + this.props.className || ""; - let small = (this.props.mode == "horizontal"); - let measurePosition=this.props.measurePosition; - let measureValues; - if (measurePosition) { - measureValues = NavCompute.computeDistance(measurePosition,this.props.centerPosition,this.props.measureRhumbLine); - } - return ( -
-
Center
- { !small &&
{Formatter.formatLonLats(this.props.centerPosition)}
} - {(measurePosition !== undefined) && + return ( + + {!small &&
{Formatter.formatLonLats(props.centerPosition)}
} + {(measurePosition !== undefined) &&
@@ -47,42 +37,41 @@ class CenterDisplayWidget extends React.Component{ nm
- } -
-
-
- {Formatter.formatDirection(this.props.markerCourse)} - ° -
-
- / -
-
- {Formatter.formatDistance(this.props.markerDistance)} - nm -
+ } +
+
+
+ {Formatter.formatDirection(props.markerCourse)} + °
-
-
-
- {Formatter.formatDirection(this.props.centerCourse)} - ° -
-
- / -
-
- {Formatter.formatDistance(this.props.centerDistance)} - nm - -
+
+ / +
+
+ {Formatter.formatDistance(props.markerDistance)} + nm
- ); - } +
+
+
+ {Formatter.formatDirection(props.centerCourse)} + ° +
+
+ / +
+
+ {Formatter.formatDistance(props.centerDistance)} + nm +
+
+ + ); } + CenterDisplayWidget.storeKeys={ markerCourse:keys.nav.center.markerCourse, markerDistance:keys.nav.center.markerDistance, @@ -94,14 +83,15 @@ CenterDisplayWidget.storeKeys={ }; CenterDisplayWidget.propTypes={ - onClick: PropTypes.func, - className: PropTypes.string, + ...WidgetProps, markerCourse:PropTypes.number, markerDistance:PropTypes.number, centerCourse:PropTypes.number, centerDistance:PropTypes.number, centerPosition: PropTypes.object, measurePosition: PropTypes.object, - measureRhumbLine: PropTypes.bool + measureRhumbLine: PropTypes.bool, + style: PropTypes.object, + mode: PropTypes.string }; export default CenterDisplayWidget; \ No newline at end of file diff --git a/viewer/components/ColorDialog.jsx b/viewer/components/ColorDialog.jsx index c94a962c0..3ef42bde8 100644 --- a/viewer/components/ColorDialog.jsx +++ b/viewer/components/ColorDialog.jsx @@ -1,54 +1,16 @@ -import React from 'react'; +import React, {useState} from 'react'; import PropTypes from 'prop-types'; import globalStore from '../util/globalstore.jsx'; import keys from '../util/keys.jsx'; import ColorPicker from '../components/ColorPicker.jsx'; import DB from './DialogButton.jsx'; +import {DialogButtons, DialogFrame} from "./OverlayDialog"; -class ColorDialog extends React.Component{ - constructor(props){ - super(props); - this.state={ - value: props.value - }; - if (!this.state.value) this.state.value="#ffffff"; - this.valueChange=this.valueChange.bind(this); - this.buttonClick=this.buttonClick.bind(this); - this.onColorChange=this.onColorChange.bind(this); - this.colorInput=this.colorInput.bind(this); - } - valueChange(ev){ - this.setState({value: ev.target.value}); - } - buttonClick(ev){ - let button=ev.target.name; - if (button == 'ok'){ - if (this.props.okCallback) this.props.okCallback(this.state.value); - } - if (button == 'unset'){ - if (this.props.okCallback) this.props.okCallback(); - } - if (button == 'reset'){ - this.setState({ - value: this.props.default - }); - return; - } - this.props.closeCallback(); - } - onColorChange(color,c){ - this.setState({ - value: color - }) - } - colorInput(ev){ - this.setState({ - value:ev.target.value - }) - } - render() { +const ColorDialog =(props)=>{ + const [value,setValue]=useState(props.value||"#ffffff"); + const ok=props.resolveFunction||props.okCallback; let style={ - backgroundColor:this.state.value, + backgroundColor:value, width: 30, height: 30 }; @@ -72,35 +34,31 @@ class ColorDialog extends React.Component{ } if (pickerProperties.saturationWidth < 50 ) pickerProperties.saturationWidth=50; return ( -
- {this.props.title?

{this.props.title}

:null} - + + setValue(v)} {...pickerProperties}/>
+ onChange={(ev)=>setValue(ev.target.value)} + value={value}/>
-
- {(this.props.default !== undefined) ? - Reset + + {(props.default !== undefined) ? + setValue(props.default)} close={false}>Reset : null} - {(this.props.showUnset !== undefined) ? - Unset + {(props.showUnset !== undefined) ? + ok()}>Unset : null} - Cancel - Ok -
-
+ Cancel + ok(value)}>Ok + + ); - } - } ColorDialog.propTypes={ - closeCallback: PropTypes.func.isRequired, okCallback: PropTypes.func, value: PropTypes.string.isRequired, default: PropTypes.string, diff --git a/viewer/components/ColorPicker.jsx b/viewer/components/ColorPicker.jsx index e6df483c0..e7368b56a 100644 --- a/viewer/components/ColorPicker.jsx +++ b/viewer/components/ColorPicker.jsx @@ -6,7 +6,6 @@ 'use strict'; import React from 'react'; -import reactCreateClass from 'create-react-class'; import assign from 'object-assign'; import tinycolor from 'tinycolor2'; diff --git a/viewer/components/CombinedWidget.jsx b/viewer/components/CombinedWidget.jsx new file mode 100644 index 000000000..2e9b61a11 --- /dev/null +++ b/viewer/components/CombinedWidget.jsx @@ -0,0 +1,200 @@ +/* +############################################################################### +# Copyright (c) 2024, Andreas Vogel andreas@wellenvogel.net + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +############################################################################### +*/ +import {useKeyEventHandler} from "../util/GuiHelpers"; +import {moveItem, useAvNavSortable, useAvnavSortContext} from "../hoc/Sortable"; +import {WidgetProps} from "./WidgetBase"; +import PropTypes from "prop-types"; +import React, {useState} from "react"; +import theFactory from "./WidgetFactory"; +import {EditableParameter} from "./EditableParameters"; +import ItemList from "./ItemList"; +import DialogButton from "./DialogButton"; +import {DialogButtons, useDialog, useDialogContext} from "./OverlayDialog"; +import EditWidgetDialog from "./EditWidgetDialog"; +import keys from "../util/keys"; + +const ChildWidget=(props)=>{ + const dd=useAvNavSortable(props.dragId); + return
+ {"sub"+props.index} +
{props.name}
+
+} + +const updateChildren=(children,index,data)=>{ + if (index === undefined) return; + let next=[...children]; + if (data !== undefined) { + next[index] = data; + } + else{ + next.splice(index,1); + } + return next; +} + +const RenderChildParam=(props)=>{ + if (! props.currentValues) return null; + const dialogContext=useDialogContext(); + const [children,setChildrenImpl]=useState(props.currentValues.children||[]) + const setChildren=(ch)=>{ + if (ch === undefined) return; + setChildrenImpl(ch); + props.onChange({children:ch}); + } + return
+ { + let next=moveItem(currentId,newId,children); + if (next !== undefined) { + setChildren(next); + } + }} + onItemClick={(item,data)=>{ + dialogContext.showDialog((props)=>{ + return { + setChildren(updateChildren(children,item.index,data)); + }} + removeCallback={()=> { + setChildren(updateChildren(children,item.index)); + }} + /> + }) + }} + /> + + { + dialogContext.showDialog((props)=>{ + return { + setChildren([...children,data]); + }} + /> + }) + }} + > + +Sub + +
+} +class ChildrenParam extends EditableParameter { + constructor() { + super('children', -1); + this.default=[]; + } + render(props){ + return + } +} + +const getWeight=(item)=>{ + if (! item) return 0; + if (item.weight === undefined) return 1; + return parseFloat(item.weight); +} + +const DEFAULT_NAME="CombinedWidget"; +export const CombinedWidget=(props)=>{ + useKeyEventHandler(props,"widget") + let {wclass,locked,editing,sequence,editableParameters,nightMode,children,onClick,childProperties,dragId,className,vertical,...forwardProps}=props; + const sortContext=useAvnavSortContext(); + const ddProps = useAvNavSortable(locked?dragId:undefined); + const cl=(ev)=>{ + if (onClick) onClick(ev); + } + let cidx = 0; + if (childProperties) delete childProperties.style; + className = (className || '') + " widget"; + if (props.name !== DEFAULT_NAME) className+=" "+DEFAULT_NAME; + if (vertical) className+=" vertical"; + let weightSum=0; + (children||[]).forEach((child)=>{ + weightSum+=getWeight(child); + }); + const dragFrame=sortContext.id+":"+dragId; + return
+ { (editing && locked) &&
Locked
} + { + sortContext.onDragEnd(oldIndex,newIndex,oldFrame,targetFrame); + }} + itemList={children||[]} + itemCreator={(item)=>{ + let weight=getWeight(item); + let percent=(weightSum !== 0)?100*weight/weightSum:undefined; + let style={}; + if (percent !== undefined){ + if (vertical) style.height=percent+"%"; + else style.width=percent+"%"; + } + let Item = theFactory.createWidget(item, {...childProperties,style:style}); + cidx++; + return (iprops)=>{ + return + }} + } + /> +
+} +CombinedWidget.propTypes={ + ...WidgetProps, + children: PropTypes.array, + vertical: PropTypes.bool, + editableParameters: PropTypes.object +} +CombinedWidget.editableParameters={ + formatter: false, + unit: false, + formatterParameters: false, + value: false, + caption: false, + vertical: {type:'BOOLEAN',default: false}, + locked: {type: 'BOOLEAN', default: true}, + children: new ChildrenParam() +} +CombinedWidget.storeKeys={ + editing: keys.gui.global.layoutEditing, + sequence: keys.gui.global.layoutSequence +} diff --git a/viewer/components/DateTimeWidget.jsx b/viewer/components/DateTimeWidget.jsx index 2ca1abe91..9235154b6 100644 --- a/viewer/components/DateTimeWidget.jsx +++ b/viewer/components/DateTimeWidget.jsx @@ -6,44 +6,27 @@ import React from "react"; import PropTypes from "prop-types"; import keys from "../util/keys.jsx"; import Formatter from "../util/formatter.js"; -import Helper from '../util/helper.js'; -import GuiHelper from '../util/GuiHelpers.js'; +import {WidgetFrame, WidgetProps} from "./WidgetBase"; -class DateTimeWidget extends React.Component{ - constructor(props){ - super(props); - GuiHelper.nameKeyEventHandler(this,"widget"); +const DateTimeWidget = (props) => { + let time = "----"; + if (props.time) { + time = Formatter.formatTime(props.time); } - shouldComponentUpdate(nextProps,nextState) { - return Helper.compareProperties(this.props,nextProps,DateTimeWidget.storeKeys); + let date = "----"; + if (props.time) { + date = Formatter.formatDate(props.time); } - render(){ - let self=this; - let classes="widget dateTimeWidget "+this.props.className||""; - let time="----"; - if (this.props.time){ - time=Formatter.formatTime(this.props.time); - } - let date="----"; - if (this.props.time){ - date=Formatter.formatDate(this.props.time); - } - return ( -
-
Date
-
+ return ( +
{date}
{time}
-
-
- ); - } - -}; + + ); +} DateTimeWidget.propTypes={ - onClick: PropTypes.func, - className: PropTypes.string, + ...WidgetProps, time: PropTypes.objectOf(Date), gpsValid: PropTypes.bool }; diff --git a/viewer/components/DialogButton.jsx b/viewer/components/DialogButton.jsx index 66d0d3c6b..b032a4b4f 100644 --- a/viewer/components/DialogButton.jsx +++ b/viewer/components/DialogButton.jsx @@ -1,39 +1,43 @@ import React from 'react'; import PropTypes from 'prop-types'; -import GuiHelper from '../util/GuiHelpers.js'; +import {useKeyEventHandlerPlain} from '../util/GuiHelpers.js'; import KeyHandler from '../util/keyhandler'; +import {concatsp} from "../util/helper"; +import {useDialogContext} from "./OverlayDialog"; -class DialogButton extends React.Component { - constructor(props){ - super(props); - let self=this; - KeyHandler.registerDialogComponent("dialogButton"); - GuiHelper.keyEventHandler(this,(component,action)=>{ - if (self.props.onClick && ! self.props.disabled) self.props.onClick(); - },"dialogButton",this.props.name); - } - render() { - let className = this.props.className || ""; - className += " dialogButton " + this.props.name; - let {icon,style,disabled,...forward}=this.props; +const COMPONENT="dialogButton"; +const DialogButton=(props)=>{ + const dialogContext=useDialogContext(); + KeyHandler.registerDialogComponent(COMPONENT); + useKeyEventHandlerPlain(props.name,COMPONENT,()=>{ + if (props.onClick && ! props.disabled && props.visible !== false) props.onClick(); + }); + let {icon,style,disabled,visible,name,className,toggle,children,onClick,close,...forward}=props; + if (visible === false) return null; let spanStyle={}; if (icon !== undefined) { - className+=" icon"; spanStyle.backgroundImage = "url(" + icon + ")"; } - className+=this.props.toggle?" active":" inactive"; let add = {}; if (disabled) { add.disabled = true; } + if (close === undefined) close=true; return ( - ); } -} DialogButton.propTypes={ onClick: PropTypes.func, @@ -42,7 +46,9 @@ DialogButton.propTypes={ icon: PropTypes.string, style: PropTypes.object, disabled: PropTypes.bool, - toggle: PropTypes.bool + toggle: PropTypes.bool, + visible: PropTypes.bool, + close: PropTypes.bool //default: true }; export default DialogButton; \ No newline at end of file diff --git a/viewer/components/DirectWidget.jsx b/viewer/components/DirectWidget.jsx index b585e9191..6fa09fc22 100644 --- a/viewer/components/DirectWidget.jsx +++ b/viewer/components/DirectWidget.jsx @@ -4,76 +4,39 @@ import React from "react"; import PropTypes from 'prop-types'; -import Helper from '../util/helper.js'; import Value from './Value.jsx'; -import GuiHelper from '../util/GuiHelpers.js'; -import assign from 'object-assign'; +import {WidgetFrame, WidgetProps} from "./WidgetBase"; -class DirectWidget extends React.Component{ - constructor(props){ - super(props); - GuiHelper.nameKeyEventHandler(this,"widget"); - this.getProps=this.getProps.bind(this); +const DirectWidget=(wprops)=>{ + const props=wprops.translateFunction?{...wprops,...wprops.translateFunction({...wprops})}:wprops; + let val; + let vdef=props.default||'0'; + if (props.value !== undefined) { + val=props.formatter?props.formatter(props.value):vdef+""; } - shouldComponentUpdate(nextProps,nextState) { - return Helper.compareProperties(this.getProps(this.props), - this.getProps(nextProps),{value:1,isAverage:1}); + else{ + if (! isNaN(vdef) && props.formatter) val=props.formatter(vdef); + else val=vdef+""; } - getProps(props){ - if (! this.props.translateFunction){ - return props; - } - else{ - return this.props.translateFunction(assign({},props)); - } - } - render(){ - let classes="widget "; - let props=this.getProps(this.props); - if (props.isAverage) classes+=" average"; - if (props.className) classes+=" "+props.className; - let val; - let vdef=props.default||'0'; - if (props.value !== undefined) { - val=this.props.formatter?this.props.formatter(props.value):vdef+""; - } - else{ - if (! isNaN(vdef) && this.props.formatter) val=this.props.formatter(vdef); - else val=vdef+""; - } - let style=props.style||{}; - - return ( -
-
+ return ( +
-
-
{props.caption}
- {this.props.unit !== undefined? -
{props.unit}
- :
- } -
- ); - } -}; + + ); +} -DirectWidget.propTypes={ +DirectWidget.propTypes = { name: PropTypes.string, unit: PropTypes.string, - caption: PropTypes.string, + ...WidgetProps, value: PropTypes.any, isAverage: PropTypes.bool, formatter: PropTypes.func.isRequired, - onClick: PropTypes.func, - className: PropTypes.string, - style: PropTypes.object, default: PropTypes.string, - translateFunction: PropTypes.func + translateFunction: PropTypes.func, }; - DirectWidget.editableParameters={ caption:true, unit:true, diff --git a/viewer/components/DownloadButton.jsx b/viewer/components/DownloadButton.jsx index 0c676e53b..b9600d5ea 100644 --- a/viewer/components/DownloadButton.jsx +++ b/viewer/components/DownloadButton.jsx @@ -22,10 +22,11 @@ # ############################################################################### */ -import React from 'react'; +import React, {useRef} from 'react'; import PropTypes from 'prop-types'; import DB from './DialogButton'; import Button from './Button'; +import Toast from "./Toast"; const toBase64=(val)=>{ if (typeof(val) === 'string'){ @@ -38,53 +39,76 @@ const toBase64=(val)=>{ return window.btoa(val); } -class DownloadButton extends React.Component{ - constructor(props) { - super(props); - this.hiddenA=undefined - } - saveLocal(){ - if (! this.hiddenA) return; - if (!this.props.localData) return false; - let data=this.props.localData; - if (typeof this.props.localData === 'function'){ - data=this.props.localData(); +const DownloadButton=(props)=>{ + const hiddenA=useRef(); + const downloadFrame=useRef(); + const saveLocal=(fileName)=>{ + if (! hiddenA.current) return; + if (!props.localData) return false; + let data=props.localData; + if (typeof data === 'function'){ + data=data(); } let dataUrl="data:application/octet-stream;base64,"+toBase64(data); - this.hiddenA.href=dataUrl; + if (window.avnav.android && window.avnav.android.dataDownload){ + window.avnav.android.dataDownload(dataUrl,fileName,"application/octet-stream"); + } + else { + hiddenA.current.href = dataUrl; + hiddenA.current.click(); + } } - render() { - let {useDialogButton,url,localData,fileName,type,androidUrl,...forward}=this.props; - let Bt = useDialogButton ? DB : Button; - if (!url && ! localData) return null; + let {useDialogButton,url,localData,fileName,type,androidUrl,...forward}=props; + let Bt = useDialogButton ? DB : Button; + if (!url && ! localData) return null; return ( - this.hiddenA = el} - href={url||""} - onClick={(ev)=>ev.stopPropagation()} - /> - { - ev.stopPropagation(); - if (! this.hiddenA) return; - if (!url) this.saveLocal(); - this.hiddenA.click(); - if (this.props.onClick) this.props.onClick(ev); + {localData && + ev.stopPropagation()} + /> + } + {!localData &&