diff --git a/examples/al-gw.html b/examples/al-gw.html index 8a4f829cf..bee55f15e 100644 --- a/examples/al-gw.html +++ b/examples/al-gw.html @@ -8,7 +8,7 @@ import A from '../src/js/A.js'; let aladin; A.init.then(() => { - aladin = A.aladin('#aladin-lite-div', {projection: "TAN", target: '15 16 57.636 -60 55 7.49', showCooGrid: true, fov: 90, fullScreen: true}); + aladin = A.aladin('#aladin-lite-div', {projection: "TAN", target: '15 16 57.636 -60 55 7.49', samp: true, showCooGrid: true, fov: 90, fullScreen: true}); var moc_0_99 = A.MOCFromURL("./gw/gw_0.9.fits",{ name: "GW 90%", color: "#ff0000", opacity: 0.7, lineWidth: 5, perimeter: true}); var moc_0_95 = A.MOCFromURL("./gw/gw_0.6.fits",{ name: "GW 60%", color: "#00ff00", opacity: 0.8, lineWidth: 5, perimeter: true}); diff --git a/src/js/A.js b/src/js/A.js index 0cf0a9d6f..c17ccc5aa 100644 --- a/src/js/A.js +++ b/src/js/A.js @@ -165,8 +165,6 @@ A.MOCFromJSON = function (jsonMOC, options) { }; -// TODO: try first without proxy, and then with, if param useProxy not set -// API A.catalogFromURL = function (url, options, successCallback, errorCallback, useProxy) { var catalog = A.catalog(options); diff --git a/src/js/Aladin.js b/src/js/Aladin.js index b97af9720..35a7b8072 100644 --- a/src/js/Aladin.js +++ b/src/js/Aladin.js @@ -51,6 +51,7 @@ import { ALEvent } from "./events/ALEvent.js"; import { Color } from './Color.js'; import { ImageFITS } from "./ImageFITS.js"; import { DefaultActionsForContextMenu } from "./DefaultActionsForContextMenu.js"; +import { SAMPConnector } from "./vo/samp.js"; import A from "./A.js"; import $ from 'jquery'; @@ -471,6 +472,13 @@ export let Aladin = (function () { this.contextMenu = new ContextMenu(this); this.contextMenu.attachTo(this.view.catalogCanvas, DefaultActionsForContextMenu.getDefaultActions(this)); } + + if (options.samp) { + this.samp = new SAMPConnector(this); + ALEvent.SAMP_AVAILABILITY.listenedBy(this.aladinDiv, function (e) { + console.log('is hub running samp', e.detail.isHubRunning) + }); + } }; /**** CONSTANTS ****/ @@ -504,6 +512,7 @@ export let Aladin = (function () { reticleColor: "rgb(178, 50, 178)", reticleSize: 22, log: true, + samp: true, allowFullZoomout: false, realFullscreen: false, showAllskyRing: false, diff --git a/src/js/View.js b/src/js/View.js index be7d0b4a5..6cafc9303 100644 --- a/src/js/View.js +++ b/src/js/View.js @@ -370,7 +370,6 @@ export let View = (function () { } this.computeNorder(); - this.redraw(); }; var pixelateCanvasContext = function (ctx, pixelateFlag) { diff --git a/src/js/events/ALEvent.js b/src/js/events/ALEvent.js index abd568a90..f52b19cc6 100644 --- a/src/js/events/ALEvent.js +++ b/src/js/events/ALEvent.js @@ -55,6 +55,8 @@ export class ALEvent { static GRAPHIC_OVERLAY_LAYER_CHANGED = new ALEvent("AL:GraphicOverlayLayer.changed"); + static SAMP_AVAILABILITY = new ALEvent("AL:samp.started"); + constructor(name) { this.name = name; } diff --git a/src/js/libs/samp.js b/src/js/libs/samp.js new file mode 100644 index 000000000..7f181a0b8 --- /dev/null +++ b/src/js/libs/samp.js @@ -0,0 +1,1319 @@ +// samp +// ---- +// Provides capabilities for using the SAMP Web Profile from JavaScript. +// Exported tokens are in the samp.* namespace. +// Inline documentation is somewhat patchy (partly because I don't know +// what javascript documentation is supposed to look like) - it is +// suggested to use it conjunction with the provided examples, +// currently visible at http://astrojs.github.com/sampjs/ +// (gh-pages branch of github sources). + +// LICENCE +// ======= +// samp.js - A Javascript module for connection to VO SAMP hubs +// Written in 2013 by Mark Taylor +// +// This file is distributed under the CC0 Public Domain Dedication, +// . +// To the extent possible under law, the author(s) have dedicated all +// copyright and related and neighboring rights to this software to the +// public domain worldwide. This software is distributed without any +// warranty. + +export let samp = (function() { + + // Constants defining well-known location of SAMP Web Profile hub etc. + var WEBSAMP_PORT = 21012; + var WEBSAMP_PATH = "/"; + var WEBSAMP_PREFIX = "samp.webhub."; + var WEBSAMP_CLIENT_PREFIX = ""; + + // Tokens representing permissible types in a SAMP object (e.g. a message) + var TYPE_STRING = "string"; + var TYPE_LIST = "list"; + var TYPE_MAP = "map"; + + var heir = function(proto) { + function F() {}; + F.prototype = proto; + return new F(); + }; + + // Utility functions for navigating DOM etc. + // ----------------------------------------- + + var getSampType = function(obj) { + if (typeof obj === "string") { + return TYPE_STRING; + } + else if (obj instanceof Array) { + return TYPE_LIST; + } + else if (obj instanceof Object && obj !== null) { + return TYPE_MAP; + } + else { + throw new Error("Not legal SAMP object type: " + obj); + } + }; + var getChildElements = function(el, childTagName) { + var children = el.childNodes; + var child; + var childEls = []; + var i; + for (i = 0; i < children.length; i++) { + child = children[i]; + if (child.nodeType === 1) { // Element + if (childTagName && (child.tagName !== childTagName)) { + throw new Error("Child <" + children[i].tagName + ">" + + " of <" + el.tagName + ">" + + " is not a <" + childTagName + ">"); + } + childEls.push(child); + } + } + return childEls; + }; + var getSoleChild = function(el, childTagName) { + var children = getChildElements(el, childTagName); + if (children.length === 1 ) { + return children[0]; + } + else { + throw new Error("No sole child of <" + el.tagName + ">"); + } + }; + var getTextContent = function(el) { + var txt = ""; + var i; + var child; + for (i = 0; i < el.childNodes.length; i++ ) { + child = el.childNodes[i]; + if (child.nodeType === 1) { // Element + throw new Error("Element found in text content"); + } + else if (child.nodeType === 3 || // Text + child.nodeType === 4 ) { // CDATASection + txt += child.nodeValue; + } + } + return txt; + }; + var stringify = function(obj) { + return typeof JSON === "undefined" ? "..." : JSON.stringify(obj); + }; + + // XmlRpc class: + // Utilities for packing and unpacking XML-RPC messages. + // See xml-rpc.com. + + var XmlRpc = {}; + + // Takes text and turns it into something suitable for use as the content + // of an XML-RPC string - special characters are escaped. + XmlRpc.escapeXml = function(s) { + return s.replace(/&/g, "&") + .replace(//g, ">"); + }; + + // Asserts that the elements of paramList match the types given by typeList. + // TypeList must be an array containing only TYPE_STRING, TYPE_LIST + // and TYPE_MAP objects in some combination. paramList must be the + // same length. + // In case of mismatch an error is thrown. + XmlRpc.checkParams = function(paramList, typeList) { + var i; + for (i = 0; i < typeList.length; i++) { + if (typeList[i] !== TYPE_STRING && + typeList[i] !== TYPE_LIST && + typeList[i] !== TYPE_MAP) { + throw new Error("Unknown type " + typeList[i] + + " in check list"); + } + } + var npar = paramList.length; + var actualTypeList = []; + var ok = true; + for (i = 0; i < npar; i++) { + actualTypeList.push(getSampType(paramList[i])); + } + ok = ok && (typeList.length === npar); + for (i = 0; ok && i < npar; i++ ) { + ok = ok && typeList[i] === actualTypeList[i]; + } + if (!ok) { + throw new Error("Param type list mismatch: " + + "[" + typeList + "] != " + + "[" + actualTypeList + "]"); + } + }; + + // Turns a SAMP object (structure of strings, lists, maps) into an + // XML string suitable for use with XML-RPC. + XmlRpc.valueToXml = function v2x(obj, prefix) { + prefix = prefix || ""; + var a; + var i; + var result; + var type = getSampType(obj); + if (type === TYPE_STRING) { + return prefix + + "" + + XmlRpc.escapeXml(obj) + + ""; + } + else if (type === TYPE_LIST) { + result = []; + result.push(prefix + "", + prefix + " ", + prefix + " "); + for (i = 0; i < obj.length; i++) { + result.push(v2x(obj[i], prefix + " ")); + } + result.push(prefix + " ", + prefix + " ", + prefix + ""); + + return result.join("\n"); + } + else if (type === TYPE_MAP) { + result = []; + result.push(prefix + ""); + result.push(prefix + " "); + for (i in obj) { + result.push(prefix + " "); + result.push(prefix + " " + + XmlRpc.escapeXml(i) + + ""); + result.push(v2x(obj[i], prefix + " ")); + result.push(prefix + " "); + } + result.push(prefix + " "); + result.push(prefix + ""); + return result.join("\n"); + } + else { + throw new Error("bad type"); // shouldn't get here + } + }; + + // Turns an XML string from and XML-RPC message into a SAMP object + // (structure of strings, lists, maps). + XmlRpc.xmlToValue = function x2v(valueEl, allowInt) { + var childEls = getChildElements(valueEl); + var i; + var j; + var txt; + var node; + var childEl; + var elName; + if (childEls.length === 0) { + return getTextContent(valueEl); + } + else if (childEls.length === 1) { + childEl = childEls[0]; + elName = childEl.tagName; + if (elName === "string") { + return getTextContent(childEl); + } + else if (elName === "array") { + var valueEls = + getChildElements(getSoleChild(childEl, "data"), "value"); + var list = []; + for (i = 0; i < valueEls.length; i++) { + list.push(x2v(valueEls[i], allowInt)); + } + return list; + } + else if (elName === "struct") { + var memberEls = getChildElements(childEl, "member"); + var map = {}; + var s_name; + var s_value; + var jc; + for (i = 0; i < memberEls.length; i++) { + s_name = undefined; + s_value = undefined; + for (j = 0; j < memberEls[i].childNodes.length; j++) { + jc = memberEls[i].childNodes[j]; + if (jc.nodeType == 1) { + if (jc.tagName === "name") { + s_name = getTextContent(jc); + } + else if (jc.tagName === "value") { + s_value = x2v(jc, allowInt); + } + } + } + if (s_name !== undefined && s_value !== undefined) { + map[s_name] = s_value; + } + else { + throw new Error("No and/or " + + "in ?"); + } + } + return map; + } + else if (allowInt && (elName === "int" || elName === "i4")) { + return getTextContent(childEl); + } + else { + throw new Error("Non SAMP-friendly value content: " + + "<" + elName + ">"); + } + } + else { + throw new Error("Bad XML-RPC content - multiple elements"); + } + }; + + // Turns the content of an XML-RPC element into an array of + // SAMP objects. + XmlRpc.decodeParams = function(paramsEl) { + var paramEls = getChildElements(paramsEl, "param"); + var i; + var results = []; + for (i = 0; i < paramEls.length; i++) { + results.push(XmlRpc.xmlToValue(getSoleChild(paramEls[i], "value"))); + } + return results; + }; + + // Turns the content of an XML-RPC element into an XmlRpc.Fault + // object. + XmlRpc.decodeFault = function(faultEl) { + var faultObj = XmlRpc.xmlToValue(getSoleChild(faultEl, "value"), true); + return new XmlRpc.Fault(faultObj.faultString, faultObj.faultCode); + }; + + // Turns an XML-RPC response element (should be ) into + // either a SAMP response object or an XmlRpc.Fault object. + // Note that a fault response does not throw an error, so check for + // the type of the result if you want to know whether a fault occurred. + // An error will however be thrown if the supplied XML does not + // correspond to a legal XML-RPC response. + XmlRpc.decodeResponse = function(xml) { + var mrEl = xml.documentElement; + if (mrEl.tagName !== "methodResponse") { + throw new Error("Response element is not "); + } + var contentEl = getSoleChild(mrEl); + if (contentEl.tagName === "fault") { + return XmlRpc.decodeFault(contentEl); + } + else if (contentEl.tagName === "params") { + return XmlRpc.decodeParams(contentEl)[0]; + } + else { + throw new Error("Bad XML-RPC response - unknown element" + + " <" + contentEl.tagName + ">"); + } + }; + + // XmlRpc.Fault class: + // Represents an XML-RPC Fault response. + XmlRpc.Fault = function(faultString, faultCode) { + this.faultString = faultString; + this.faultCode = faultCode; + }; + XmlRpc.Fault.prototype.toString = function() { + return "XML-RPC Fault (" + this.faultCode + "): " + this.faultString; + }; + + // XmlRpcRequest class: + // Represents an call which can be sent to an XML-RPC server. + var XmlRpcRequest = function(methodName, params) { + this.methodName = methodName; + this.params = params || []; + } + XmlRpcRequest.prototype.toString = function() { + return this.methodName + "(" + stringify(this.params) + ")"; + }; + XmlRpcRequest.prototype.addParam = function(param) { + this.params.push(param); + return this; + }; + XmlRpcRequest.prototype.addParams = function(params) { + var i; + for (i = 0; i < params.length; i++) { + this.params.push(params[i]); + } + return this; + }; + XmlRpcRequest.prototype.checkParams = function(typeList) { + XmlRpc.checkParams(this.params, typeList); + }; + XmlRpcRequest.prototype.toXml = function() { + var lines = []; + lines.push( + "", + "", + " " + this.methodName + "", + " "); + for (var i = 0; i < this.params.length; i++) { + lines.push(" ", + XmlRpc.valueToXml(this.params[i], " "), + " "); + } + lines.push( + " ", + ""); + return lines.join("\n"); + }; + + // XmlRpcClient class: + // Object capable of sending XML-RPC calls to an XML-RPC server. + // That server will typically reside on the host on which the + // javascript is running; it is not likely to reside on the host + // which served the javascript. That means that sandboxing restrictions + // will be in effect. Much of the work done here is therefore to + // do the client-side work required to potentially escape the sandbox. + // The endpoint parameter, if supplied, is the URL of the XML-RPC server. + // If absent, the default SAMP Web Profile server is used. + var XmlRpcClient = function(endpoint) { + this.endpoint = endpoint || + "http://localhost:" + WEBSAMP_PORT + WEBSAMP_PATH; + }; + + // Creates an XHR facade - an object that presents an interface + // resembling that of an XMLHttpRequest Level 2. + // This facade may be based on an actual XMLHttpRequest Level 2 object + // (on browsers that support it), or it may fake one using other + // available technology. + // + // The created facade in any case presents the following interface: + // + // open(method, url) + // send(body) + // abort() + // setContentType() + // responseText + // responseXML + // onload + // onerror(err) - includes timeout; abort is ignored + // + // See the documentation at http://www.w3.org/TR/XMLHttpRequest/ + // for semantics. + // + // XMLHttpRequest Level 2 supports Cross-Origin Resource Sharing (CORS) + // which makes sandbox evasion possible. Faked XHRL2s returned by + // this method may use CORS or some other technology to evade the + // sandbox. The SAMP hub itself may selectively allow some of these + // technologies and not others, according to configuration. + XmlRpcClient.createXHR = function() { + + // Creates an XHR facade based on a genuine XMLHttpRequest Level 2. + var XhrL2 = function(xhr) { + this.xhr = xhr; + xhr.onreadystatechange = (function(l2) { + return function() { + if (xhr.readyState !== 4) { + return; + } + else if (!l2.completed) { + if (+xhr.status === 200) { + l2.completed = true; + l2.responseText = xhr.responseText; + l2.responseXML = xhr.responseXML; + if (l2.onload) { + l2.onload(); + } + } + } + }; + })(this); + xhr.onerror = (function(l2) { + return function(event) { + if (!l2.completed) { + l2.completed = true; + if (l2.onerror) { + if (event) { + event.toString = function() {return "No hub?";}; + } + else { + event = "No hub?"; + } + l2.onerror(event); + } + } + }; + })(this); + xhr.ontimeout = (function(l2) { + return function(event) { + if (!l2.completed) { + l2.completed = true; + if (l2.onerror) { + l2.onerror("timeout"); + } + } + }; + })(this); + }; + XhrL2.prototype.open = function(method, url) { + this.xhr.open(method, url); + }; + XhrL2.prototype.send = function(body) { + this.xhr.send(body); + }; + XhrL2.prototype.abort = function() { + this.xhr.abort(); + } + XhrL2.prototype.setContentType = function(mimeType) { + if ("setRequestHeader" in this.xhr) { + this.xhr.setRequestHeader("Content-Type", mimeType); + } + } + + // Creates an XHR facade based on an XDomainRequest (IE8+ only). + var XdrL2 = function(xdr) { + this.xdr = xdr; + xdr.onload = (function(l2) { + return function() { + var e; + l2.responseText = xdr.responseText; + if (xdr.contentType === "text/xml" || + xdr.contentType === "application/xml" || + /\/x-/.test(xdr.contentType)) { + try { + var xdoc = new ActiveXObject("Microsoft.XMLDOM"); + xdoc.loadXML(xdr.responseText); + l2.responseXML = xdoc; + } + catch (e) { + l2.responseXML = e; + } + } + if (l2.onload) { + l2.onload(); + } + }; + })(this); + xdr.onerror = (function(l2) { + return function(event) { + if (l2.onerror) { + l2.onerror(event); + } + }; + })(this); + xdr.ontimeout = (function(l2) { + return function(event) { + if (l2.onerror) { + l2.onerror(event); + } + }; + })(this); + }; + XdrL2.prototype.open = function(method, url) { + this.xdr.open(method, url); + }; + XdrL2.prototype.send = function(body) { + this.xdr.send(body); + }; + XdrL2.prototype.abort = function() { + this.xdr.abort(); + }; + XdrL2.prototype.setContentType = function(mimeType) { + // can't do it. + }; + + // Creates an XHR Facade based on available XMLHttpRequest-type + // capabilibities. + // If an actual XMLHttpRequest Level 2 is available, use that. + if (typeof XMLHttpRequest !== "undefined") { + var xhr = new XMLHttpRequest(); + if ("withCredentials" in xhr) { + return new XhrL2(xhr); + } + } + + // Else if an XDomainRequest is available, use that. + if (typeof XDomainRequest !== "undefined") { + return new XdrL2(new XDomainRequest()); + } + + // Else fake an XMLHttpRequest using Flash/flXHR, if available + // and use that. + if (typeof flensed.flXHR !== "undefined") { + return new XhrL2(new flensed.flXHR({instancePooling: true})); + } + + // No luck. + throw new Error("no cross-origin mechanism available"); + }; + + // Executes a request by passing it to the XML-RPC server. + // On success, the result is passed to the resultHandler. + // On failure, the errHandler is called with one of two possible + // arguments: an XmlRpc.Fault object, or an Error object. + XmlRpcClient.prototype.execute = function(req, resultHandler, errHandler) { + (function(xClient) { + var xhr; + var e; + try { + xhr = XmlRpcClient.createXHR(); + xhr.open("POST", xClient.endpoint); + xhr.setContentType("text/xml"); + } + catch (e) { + errHandler(e); + throw e; + } + xhr.onload = function() { + var xml = xhr.responseXML; + var result; + var e; + if (xml) { + try { + result = XmlRpc.decodeResponse(xml); + } + catch (e) { + if (errHandler) { + errHandler(e); + } + return; + } + } + else { + if (errHandler) { + errHandler("no XML response"); + } + return; + } + if (result instanceof XmlRpc.Fault) { + if (errHandler) { + errHandler(result); + } + } + else { + if (resultHandler) { + resultHandler(result); + } + } + }; + xhr.onerror = function(event) { + if (event) { + event.toString = function() {return "No hub?";} + } + else { + event = "No hub"; + } + if (errHandler) { + errHandler(event); + } + }; + xhr.send(req.toXml()); + return xhr; + })(this); + }; + + // Message class: + // Aggregates an MType string and a params map. + var Message = function(mtype, params) { + this["samp.mtype"] = mtype; + this["samp.params"] = params; + }; + + // Connection class: + // this is what clients use to communicate with the hub. + // + // All the methods from the Hub Abstract API as described in the + // SAMP standard are available as methods of a Connection object. + // The initial private-key argument required by the Web Profile is + // handled internally by this object - you do not need to supply it + // when calling one of the methods. + // + // All these calls have the same form: + // + // connection.method([method-args], resultHandler, errorHandler) + // + // the first argument is an array of the arguments (as per the SAMP + // abstract hub API), the second argument is a function which is + // called on successful completion with the result of the SAMP call + // as its argument, and the third argument is a function which is + // called on unsuccessful completion with an error object as its + // argument. The resultHandler and errorHandler arguments are optional. + // + // So for instance if you have a Connection object conn, + // you can send a notify message to all other clients by doing, e.g.: + // + // conn.notifyAll([new samp.Message(mtype, params)]) + // + // Connection has other methods as well as the hub API ones + // as documented below. + var Connection = function(regInfo) { + this.regInfo = regInfo; + this.privateKey = regInfo["samp.private-key"]; + if (! typeof(this.privateKey) === "string") { + throw new Error("Bad registration object"); + } + this.xClient = new XmlRpcClient(); + }; + (function() { + var connMethods = { + call: [TYPE_STRING, TYPE_STRING, TYPE_MAP], + callAll: [TYPE_STRING, TYPE_MAP], + callAndWait: [TYPE_STRING, TYPE_MAP, TYPE_STRING], + declareMetadata: [TYPE_MAP], + declareSubscriptions: [TYPE_MAP], + getMetadata: [TYPE_STRING], + getRegisteredClients: [], + getSubscribedClients: [TYPE_STRING], + getSubscriptions: [TYPE_STRING], + notify: [TYPE_STRING, TYPE_MAP], + notifyAll: [TYPE_MAP], + ping: [], + reply: [TYPE_STRING, TYPE_MAP] + }; + var fn; + var types; + for (fn in connMethods) { + (function(fname, types) { + // errHandler may be passed an XmlRpc.Fault or a thrown Error. + Connection.prototype[fname] = + function(sampArgs, resultHandler, errHandler) { + var closer = + (function(c) {return function() {c.close()}})(this); + errHandler = errHandler || closer + XmlRpc.checkParams(sampArgs, types); + var request = new XmlRpcRequest(WEBSAMP_PREFIX + fname); + request.addParam(this.privateKey); + request.addParams(sampArgs); + return this.xClient. + execute(request, resultHandler, errHandler); + }; + })(fn, connMethods[fn]); + } + })(); + Connection.prototype.unregister = function() { + var e; + if (this.callbackRequest) { + try { + this.callbackRequest.abort(); + } + catch (e) { + } + } + var request = new XmlRpcRequest(WEBSAMP_PREFIX + "unregister"); + request.addParam(this.privateKey); + try { + this.xClient.execute(request); + } + catch (e) { + // log unregister failed + } + delete this.regInfo; + delete this.privateKey; + }; + + // Closes this connection. It unregisters from the hub if still + // registered, but may harmlessly be called multiple times. + Connection.prototype.close = function() { + var e; + if (this.closed) { + return; + } + this.closed = true; + try { + if (this.regInfo) { + this.unregister(); + } + } + catch (e) { + } + if (this.onclose) { + oc = this.onclose; + delete this.onclose; + try { + oc(); + } + catch (e) { + } + } + }; + + // Arranges for this connection to receive callbacks. + // + // The callableClient argument must be an object implementing the + // SAMP callable client API, i.e. it must have the following methods: + // + // receiveNotification(string sender-id, map message) + // receiveCall(string sender-id, string msg-id, map message) + // receiveResponse(string responder-id, string msg-tag, map response) + // + // The successHandler argument will be called with no arguments if the + // allowCallbacks hub method completes successfully - it is a suitable + // hook to use for declaring subscriptions. + // + // The CallableClient class provides a suitable implementation, see below. + Connection.prototype.setCallable = function(callableClient, + successHandler) { + var e; + if (this.callbackRequest) { + try { + this.callbackRequest.abort(); + } + catch (e) { + } + finally { + delete this.callbackRequest; + } + } + if (!callableClient && !this.regInfo) { + return; + } + var request = + new XmlRpcRequest(WEBSAMP_PREFIX + "allowReverseCallbacks"); + request.addParam(this.privateKey); + request.addParam(callableClient ? "1" : "0"); + var closer = (function(c) {return function() {c.close()}})(this); + if (callableClient) { + (function(connection) { + var invokeCallback = function(callback) { + var methodName = callback["samp.methodName"]; + var methodParams = callback["samp.params"]; + var handlerFunc = undefined; + if (methodName === WEBSAMP_CLIENT_PREFIX + + "receiveNotification") { + handlerFunc = callableClient.receiveNotification; + } + else if (methodName === WEBSAMP_CLIENT_PREFIX + + "receiveCall") { + handlerFunc = callableClient.receiveCall; + } + else if (methodName === WEBSAMP_CLIENT_PREFIX + + "receiveResponse") { + handlerFunc = callableClient.receiveResponse; + } + else { + // unknown callback?? + } + if (handlerFunc) { + handlerFunc.apply(callableClient, methodParams); + } + }; + var startTime; + var resultHandler = function(result) { + if (getSampType(result) != TYPE_LIST) { + errHandler(new Error("pullCallbacks result not List")); + return; + } + var i; + var e; + for (i = 0; i < result.length; i++) { + try { + invokeCallback(result[i]); + } + catch (e) { + // log here? + } + } + callWaiter(); + }; + var errHandler = function(error) { + var elapsed = new Date().getTime() - startTime; + if (elapsed < 1000) { + connection.close() + } + else { + // probably a timeout + callWaiter(); + } + }; + var callWaiter = function() { + if (!connection.regInfo) { + return; + } + var request = + new XmlRpcRequest(WEBSAMP_PREFIX + "pullCallbacks"); + request.addParam(connection.privateKey); + request.addParam("600"); + startTime = new Date().getTime(); + connection.callbackRequest = + connection.xClient. + execute(request, resultHandler, errHandler); + }; + var sHandler = function() { + callWaiter(); + successHandler(); + }; + connection.xClient.execute(request, sHandler, closer); + })(this); + } + else { + this.xClient.execute(request, successHandler, closer); + } + }; + + // Takes a public URL and returns a URL that can be used from within + // this javascript context. Some translation may be required, since + // a URL sent by an external application may be cross-domain, in which + // case browser sandboxing would typically disallow access to it. + Connection.prototype.translateUrl = function(url) { + var translator = this.regInfo["samp.url-translator"] || ""; + return translator + url; + }; + Connection.Action = function(actName, actArgs, resultKey) { + this.actName = actName; + this.actArgs = actArgs; + this.resultKey = resultKey; + }; + + // Suitable implementation for a callable client object which can + // be supplied to Connection.setCallable(). + // Its callHandler and replyHandler members are string->function maps + // which can be used to provide handler functions for MTypes and + // message tags respectively. + // + // In more detail: + // The callHandler member maps a string representing an MType to + // a function with arguments (senderId, message, isCall). + // The replyHandler member maps a string representing a message tag to + // a function with arguments (responderId, msgTag, response). + var CallableClient = function(connection) { + this.callHandler = {}; + this.replyHandler = {}; + }; + CallableClient.prototype.init = function(connection) { + }; + CallableClient.prototype.receiveNotification = function(senderId, message) { + var mtype = message["samp.mtype"]; + var handled = false; + var e; + if (mtype in this.callHandler) { + try { + this.callHandler[mtype](senderId, message, false); + } + catch (e) { + } + handled = true; + } + return handled; + }; + CallableClient.prototype.receiveCall = function(senderId, msgId, message) { + var mtype = message["samp.mtype"]; + var handled = false; + var response; + var result; + var e; + if (mtype in this.callHandler) { + try { + result = this.callHandler[mtype](senderId, message, true) || {}; + response = {"samp.status": "samp.ok", + "samp.result": result}; + handled = true; + } + catch (e) { + response = {"samp.status": "samp.error", + "samp.error": {"samp.errortxt": e.toString()}}; + } + } + else { + response = {"samp.status": "samp.warning", + "samp.result": {}, + "samp.error": {"samp.errortxt": "no action"}}; + } + this.connection.reply([msgId, response]); + return handled; + }; + CallableClient.prototype.receiveResponse = function(responderId, msgTag, + response) { + var handled = false; + var e; + if (msgTag in this.replyHandler) { + try { + this.replyHandler[msgTag](responderId, msgTag, response); + handled = true; + } + catch (e) { + } + } + return handled; + }; + CallableClient.prototype.calculateSubscriptions = function() { + var subs = {}; + var mt; + for (mt in this.callHandler) { + subs[mt] = {}; + } + return subs; + }; + + // ClientTracker is a CallableClient which also provides tracking of + // registered clients. + // + // Its onchange member, if defined, will be called with arguments + // (client-id, change-type, associated-data) whenever the list or + // characteristics of registered clients has changed. + var ClientTracker = function() { + var tracker = this; + this.ids = {}; + this.metas = {}; + this.subs = {}; + this.replyHandler = {}; + this.callHandler = { + "samp.hub.event.shutdown": function(senderId, message) { + tracker.connection.close(); + }, + "samp.hub.disconnect": function(senderId, message) { + tracker.connection.close(); + }, + "samp.hub.event.register": function(senderId, message) { + var id = message["samp.params"]["id"]; + tracker.ids[id] = true; + tracker.changed(id, "register", null); + }, + "samp.hub.event.unregister": function(senderId, message) { + var id = message["samp.params"]["id"]; + delete tracker.ids[id]; + delete tracker.metas[id]; + delete tracker.subs[id]; + tracker.changed(id, "unregister", null); + }, + "samp.hub.event.metadata": function(senderId, message) { + var id = message["samp.params"]["id"]; + var meta = message["samp.params"]["metadata"]; + tracker.metas[id] = meta; + tracker.changed(id, "meta", meta); + }, + "samp.hub.event.subscriptions": function(senderId, message) { + var id = message["samp.params"]["id"]; + var subs = message["samp.params"]["subscriptions"]; + tracker.subs[id] = subs; + tracker.changed(id, "subs", subs); + } + }; + }; + ClientTracker.prototype = heir(CallableClient.prototype); + ClientTracker.prototype.changed = function(id, type, data) { + if (this.onchange) { + this.onchange(id, type, data); + } + }; + ClientTracker.prototype.init = function(connection) { + var tracker = this; + this.connection = connection; + var retrieveInfo = function(id, type, infoFuncName, infoArray) { + connection[infoFuncName]([id], function(info) { + infoArray[id] = info; + tracker.changed(id, type, info); + }); + }; + connection.getRegisteredClients([], function(idlist) { + var i; + var id; + tracker.ids = {}; + for (i = 0; i < idlist.length; i++) { + id = idlist[i]; + tracker.ids[id] = true; + retrieveInfo(id, "meta", "getMetadata", tracker.metas); + retrieveInfo(id, "subs", "getSubscriptions", tracker.subs); + } + tracker.changed(null, "ids", null); + }); + }; + ClientTracker.prototype.getName = function(id) { + var meta = this.metas[id]; + return (meta && meta["samp.name"]) ? meta["samp.name"] : "[" + id + "]"; + }; + + // Connector class: + // A higher level class which can manage transparent hub + // registration/unregistration and client tracking. + // + // On construction, the name argument is mandatory, and corresponds + // to the samp.name item submitted at registration time. + // The other arguments are optional. + // meta is a metadata map (if absent, no metadata is declared) + // callableClient is a callable client object for receiving callbacks + // (if absent, the client is not callable). + // subs is a subscriptions map (if absent, no subscriptions are declared) + var Connector = function(name, meta, callableClient, subs) { + this.name = name; + this.meta = meta; + this.callableClient = callableClient; + this.subs = subs; + this.regTextNodes = []; + this.whenRegs = []; + this.whenUnregs = []; + this.connection = undefined; + this.onreg = undefined; + this.onunreg = undefined; + }; + var setRegText = function(connector, txt) { + var i; + var nodes = connector.regTextNodes; + var node; + for (i = 0; i < nodes.length; i++) { + node = nodes[i]; + node.innerHTML = ""; + node.appendChild(document.createTextNode(txt)); + } + }; + Connector.prototype.setConnection = function(conn) { + var connector = this; + var e; + if (this.connection) { + this.connection.close(); + if (this.onunreg) { + try { + this.onunreg(); + } + catch (e) { + } + } + } + this.connection = conn; + if (conn) { + conn.onclose = function() { + connector.connection = null; + if (connector.onunreg) { + try { + connector.onunreg(); + } + catch (e) { + } + } + connector.update(); + }; + if (this.meta) { + conn.declareMetadata([this.meta]); + } + if (this.callableClient) { + if (this.callableClient.init) { + this.callableClient.init(conn); + } + conn.setCallable(this.callableClient, function() { + conn.declareSubscriptions([connector.subs]); + }); + } + if (this.onreg) { + try { + this.onreg(conn); + } + catch (e) { + } + } + } + this.update(); + }; + Connector.prototype.register = function() { + var connector = this; + var regErrHandler = function(err) { + setRegText(connector, "no (" + err.toString() + ")"); + }; + var regSuccessHandler = function(conn) { + connector.setConnection(conn); + setRegText(connector, conn ? "Yes" : "No"); + }; + register(this.name, regSuccessHandler, regErrHandler); + }; + Connector.prototype.unregister = function() { + if (this.connection) { + this.connection.unregister([]); + this.setConnection(null); + } + }; + + // Returns a document fragment which contains Register/Unregister + // buttons for use by the user to attempt to connect/disconnect + // with the hub. This is useful for models where explicit + // user registration is encouraged or required, but when using + // the register-on-demand model such buttons are not necessary. + Connector.prototype.createRegButtons = function() { + var connector = this; + var regButt = document.createElement("button"); + regButt.setAttribute("type", "button"); + regButt.appendChild(document.createTextNode("Register")); + regButt.onclick = function() {connector.register();}; + this.whenUnregs.push(regButt); + var unregButt = document.createElement("button"); + unregButt.setAttribute("type", "button"); + unregButt.appendChild(document.createTextNode("Unregister")); + unregButt.onclick = function() {connector.unregister();}; + this.whenRegs.push(unregButt); + var regText = document.createElement("span"); + this.regTextNodes.push(regText); + var node = document.createDocumentFragment(); + node.appendChild(regButt); + node.appendChild(document.createTextNode(" ")); + node.appendChild(unregButt); + var label = document.createElement("span"); + label.innerHTML = " Registered: "; + node.appendChild(label); + node.appendChild(regText); + this.update(); + return node; + }; + + Connector.prototype.update = function() { + var i; + var isConnected = !! this.connection; + var enableds = isConnected ? this.whenRegs : this.whenUnregs; + var disableds = isConnected ? this.whenUnregs : this.whenRegs; + for (i = 0; i < enableds.length; i++) { + enableds[i].removeAttribute("disabled"); + } + for (i = 0; i < disableds.length; i++) { + disableds[i].setAttribute("disabled", "disabled"); + } + setRegText(this, "No"); + }; + + // Provides execution of a SAMP operation with register-on-demand. + // You can use this method to provide lightweight registration/use + // of web SAMP. Simply provide a connHandler function which + // does something with a connection (e.g. sends a message) and + // Connector.runWithConnection on it. This will connect if not + // already connected, and call the connHandler on with the connection. + // No explicit registration action is then required from the user. + // + // If the regErrorHandler argument is supplied, it is a function of + // one (error) argument called in the case that registration-on-demand + // fails. + // + // This is a more-or-less complete sampjs page: + // + // + Connector.prototype.runWithConnection = + function(connHandler, regErrorHandler) { + var connector = this; + var regSuccessHandler = function(conn) { + connector.setConnection(conn); + connHandler(conn); + }; + var regFailureHandler = function(e) { + connector.setConnection(undefined); + regErrorHandler(e); + }; + var pingResultHandler = function(result) { + connHandler(connector.connection); + }; + var pingErrorHandler = function(err) { + register(this.name, regSuccessHandler, regFailureHandler); + }; + if (this.connection) { + // Use getRegisteredClients as the most lightweight check + // I can think of that this connection is still OK. + // Ping doesn't work because the server replies even if the + // private-key is incorrect/invalid. Is that a bug or not? + this.connection. + getRegisteredClients([], pingResultHandler, pingErrorHandler); + } + else { + register(this.name, regSuccessHandler, regFailureHandler); + } + }; + + // Sets up an interval timer to run at intervals and notify a callback + // about whether a hub is currently running. + // Every millis milliseconds, the supplied availHandler function is + // called with a boolean argument: true if a (web profile) hub is + // running, false if not. + // Returns the interval timer (can be passed to clearInterval()). + Connector.prototype.onHubAvailability = function(availHandler, millis) { + samp.ping(availHandler); + + // Could use the W3C Page Visibility API to avoid making these + // checks when the page is not visible. + return setInterval(function() {samp.ping(availHandler);}, millis); + }; + + // Determines whether a given subscriptions map indicates subscription + // to a given mtype. + var isSubscribed = function(subs, mtype) { + var matching = function(pattern, mtype) { + if (pattern == mtype) { + return true; + } + else if (pattern === "*") { + return true; + } + else { + var prefix; + var split = /^(.*)\.\*$/.exec(pat); + if (split) { + prefix = split[1]; + if (prefix === mtype.substring(0, prefix.length)) { + return true; + } + } + } + return false; + }; + var pat; + for (pat in subs) { + if (matching(pat, mtype)) { + return true; + } + } + return false; + } + + // Attempts registration with a SAMP hub. + // On success the supplied connectionHandler function is called + // with the connection as an argument, on failure the supplied + // errorHandler is called with an argument that may be an Error + // or an XmlRpc.Fault. + var register = function(appName, connectionHandler, errorHandler) { + var xClient = new XmlRpcClient(); + var regRequest = new XmlRpcRequest(WEBSAMP_PREFIX + "register"); + var securityInfo = {"samp.name": appName}; + regRequest.addParam(securityInfo); + regRequest.checkParams([TYPE_MAP]); + var resultHandler = function(result) { + var conn; + var e; + try { + conn = new Connection(result); + } + catch (e) { + errorHandler(e); + return; + } + connectionHandler(conn); + }; + xClient.execute(regRequest, resultHandler, errorHandler); + }; + + // Calls the hub ping method once. It is not necessary to be + // registered to do this. + // The supplied pingHandler function is called with a boolean argument: + // true if a (web profile) hub is running, false if not. + var ping = function(pingHandler) { + var xClient = new XmlRpcClient(); + var pingRequest = new XmlRpcRequest(WEBSAMP_PREFIX + "ping"); + var resultHandler = function(result) { + pingHandler(true); + }; + var errorHandler = function(error) { + pingHandler(false); + }; + xClient.execute(pingRequest, resultHandler, errorHandler); + }; + + + /* Exports. */ + var jss = {}; + jss.XmlRpcRequest = XmlRpcRequest; + jss.XmlRpcClient = XmlRpcClient; + jss.Message = Message; + jss.TYPE_STRING = TYPE_STRING; + jss.TYPE_LIST = TYPE_LIST; + jss.TYPE_MAP = TYPE_MAP; + jss.register = register; + jss.ping = ping; + jss.isSubscribed = isSubscribed; + jss.Connector = Connector; + jss.Connection = Connection; + jss.CallableClient = CallableClient; + jss.ClientTracker = ClientTracker; + + return jss; +})(); \ No newline at end of file diff --git a/src/js/vo/samp.js b/src/js/vo/samp.js new file mode 100644 index 000000000..b469e01e6 --- /dev/null +++ b/src/js/vo/samp.js @@ -0,0 +1,180 @@ +// Copyright 2013 - UDS/CNRS +// The Aladin Lite program is distributed under the terms +// of the GNU General Public License version 3. +// +// This file is part of Aladin Lite. +// +// Aladin Lite is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// Aladin Lite is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// The GNU General Public License is available in COPYING file +// along with Aladin Lite. +// + + +/****************************************************************************** + * Aladin Lite project + * + * File vo/samp.js + * + * + * Author: Matthieu Baumann [CDS, matthieu.baumann@astro.unistra.fr] + * + *****************************************************************************/ + +import { ALEvent } from "../events/ALEvent"; +import { samp } from '../libs/samp'; + +export class SAMPConnector { + constructor(aladin) { + // Define listeners + let cc = new samp.ClientTracker(); + let callHandler = cc.callHandler; + + callHandler["script.aladin.send"] = function(senderId, message, isCall) { + var params = message["samp.params"]; + aladin.setBaseImageLayer(params["url"]) + }; + + callHandler["coord.pointAt.sky"] = function(senderId, message, isCall) { + var params = message["samp.params"]; + + aladin.gotoRaDec(+params["ra"], +params["dec"]) + }; + + callHandler["coverage.load.moc.fits"] = function(senderId, message, isCall) { + var params = message["samp.params"]; + + let name = params["name"]; + let moc = A.MOCFromURL(params["url"], {name: name, lineWidth: 3}); + aladin.addMOC(moc); + }; + + callHandler["image.load.fits"] = function(senderId, message, isCall) { + let params = message["samp.params"]; + + let url = params["url"]; + let name = params["name"]; + const image = aladin.createImageFITS(url, name, options, (e) => window.alert(e)); + + aladin.setOverlayImageLayer(image, name); + }; + + callHandler["table.load.votable"] = function(senderId, message, isCall) { + let params = message["samp.params"]; + + let url = params["url"]; + let name = params["name"]; + + let cat = A.catalogFromURL( + url, + {name: name, onClick: 'showTable'}, + null, + (e) => window.alert(e) + ); + aladin.addCatalog(cat) + }; + + let subs = cc.calculateSubscriptions(); + let meta = { + "samp.name": "Aladin Lite", + "samp.description": "Aladin Lite web visualizer SAMP connector" + }; + // Arrange for document to be adjusted for presence of hub every 2 sec. + this.connector = new samp.Connector("Aladin Lite", meta, cc, subs); + window.addEventListener('load', (e) => { + this.connector.onHubAvailability((isHubRunning) => { + // Communicate to Aladin Lite + ALEvent.SAMP_AVAILABILITY.dispatchedTo(aladin.aladinDiv, { isHubRunning: isHubRunning } ); + }, 2000); + }); + + window.addEventListener('unload', (e) => { + this.connector.unregister(); + }); + + // TODO put that in a button + //this.connector.register(); + } + + // Broadcasts a message given a hub connection. + _send(mtype, params) { + // Provides execution of a SAMP operation with register-on-demand. + this.connector.runWithConnection( + (connection) => { + let msg = new samp.Message(mtype, params); + connection.notifyAll([msg]); + }, + (e) => { + window.alert(e) + } + ) + } + + /** + * Load a VOTable by url + * @param {String} url - URL of the VOTable document to load + * @param {String} [tableId] - Identifier which may be used to refer to the loaded table in subsequent messages + * @param {String} [name] - Name which may be used to label the loaded table in the application GUI + */ + loadVOTable(url, tableId, name) { + this._send("table.load.votable", { + url: url, + "table-id": tableId, + name: name + }) + } + + /** + * Load a fits image by url + * @param {String} url - URL of the FITS image to load + * @param {String} [imageId] - Identifier which may be used to refer to the loaded image in subsequent messages + * @param {String} [name] - Name which may be used to label the loaded image in the application GUI + */ + loadImageFITS(url, imageId, name) { + this._send("image.load.fits", { + "url": url, + "image-id": imageId, + "name": name + }) + } + + /** + * Load a Multi-Order-Coverage FITS file + * @param {String} url - URL of a FITS file containing the MOC to load + * @param {String} [coverageId] - Identifier which may be used to refer to the loaded coverage specification in subsequent messages + * @param {String} [name] - Name which may be used to label the loaded image in the application GUI + */ + loadMocFITS(url, coverageId, name) { + this._send("coverage.load.moc.fits", { + "url": url, + "coverage-id": coverageId, + "name": name + }) + } + + /** + * Load a HiPS by an url + * @param {String} url - base URL for the HiPS to load + */ + loadHiPS(url) { + const cmd = 'load ' + url; + this._send("script.aladin.send", { "script": cmd }) + } + + /** + * Send a ra, dec position to the hub + * @param {Float} ra - right ascension in degrees + * @param {Float} dec - declination in degrees + */ + centerAtRaDec(ra, dec) { + this._send("coord.pointAt.sky", { "ra": ra.toString(), "dec": dec.toString() }) + } +} +