/* webpage to display kalman filter output * coordinates with ex_websock.py in this directory */ import * as d3 from "https://cdn.skypack.dev/d3@7"; /* json5 accepts ieee floatingpoint special values; * regular json excludes them (!?#) */ import JSON5 from "https://unpkg.com/json5@2/dist/index.min.mjs"; /* NOTE: put "export" in front of a variable/function * that we want to make accessible outside this module */ /* for use for browser's javascript console */ globalThis.d3 = d3; //globalThis.jparse = JSON5.parse; /* u: document.URL */ function choose_ws_url(suffix_url) { var pcol; var u = document.URL; /* * We open the websocket encrypted if this page came on an * https:// url itself, otherwise unencrypted */ if (u.substring(0, 5) === "https") { pcol = "wss://"; u = u.substr(8); } else { pcol = "ws://"; if (u.substring(0, 4) === "http") u = u.substr(7); } u = u.split("/"); /* + "/xxx" bit is for IE10 workaround */ return pcol + u[0] + "/" + suffix_url; } /*choose_ws_url*/ class Datatype { #typename = null; #nominal = null; /* .from_json(x) convert a value received in json format * to native representation. */ #from_json = null; /* .make_scale(range) builds d3 scale object */ #make_scale = null; constructor(typename, nominal, from_json, make_scale) { this.#typename = typename; this.#nominal = nominal; this.#from_json = from_json; this.#make_scale = make_scale; } typename() { return this.#typename; } nominal() { return this.#nominal; } from_json(x) { return this.#from_json(x); } make_scale(domain) { return this.#make_scale(domain); } }; /*Datatype*/ class DatatypeFactory { static dtype_map = DatatypeFactory.make_dtype_map(); static make_float_dtype() { return new Datatype("float" /*typename*/, 0.0 /*nominal*/, (x) => { return x; } /*from_json*/, (dom) => { return d3.scaleLinear().domain(dom); } /*make_scale*/ ); } static make_datetime_dtype() { return new Datatype("datetime" /*typename*/, new Date() /*nominal*/, (x) => { return new Date(x); } /*from_json*/, (dom) => { return d3.scaleTime().domain(dom); } /*make_scale*/ ); } static make_dtype_map() { let retval = new Map(); retval.set("float", DatatypeFactory.make_float_dtype()); retval.set("datetime", DatatypeFactory.make_datetime_dtype()); return retval; } static lookup(typename) { if (DatatypeFactory.dtype_map.has(typename)) { return DatatypeFactory.dtype_map.get(typename); } else { throw new Error("DatatypeFactory: typename [" + typename + "] found where float|datetime expected"); } } }; /*DatatypeFactory*/ /* class to extract event values for charting. * 'traits' because applies to separately-represented event objects */ class DataTraits { /* .x_slotlookup(ev) => x-value */ #x_slotlookup = null; /* .y_slotlookup(ev) => y-value */ #y_slotlookup = null; #x_datatype = null; //DatatypeFactory.lookup("datetime"); #y_datatype = null; //DatatypeFactory.lookup("float"); /* x_nt, y_nt: each should be a pair [slotlookup, typename] * - slotname is a function :: event -> jsonvalue, * that extracts an attribute from incoming event in json format * - typename is float|datetime */ constructor(x_nt, y_nt) { this.#x_slotlookup = x_nt[0]; this.#x_datatype = DatatypeFactory.lookup(x_nt[1]); this.#y_slotlookup = y_nt[0]; this.#y_datatype = DatatypeFactory.lookup(y_nt[1]); } x_datatype() { return this.#x_datatype; } y_datatype() { return this.#y_datatype; } x_nominal() { return this.#x_datatype.nominal(); } y_nominal() { return this.#y_datatype.nominal(); } mapkey(data_ev) { return this.#x_slotlookup(data_ev); } x_value(data_ev) { return this.#x_datatype.from_json(this.#x_slotlookup(data_ev)); } y_value(data_ev) { return this.#y_datatype.from_json(this.#y_slotlookup(data_ev)); } make_x_scale(domain) { return this.#x_datatype.make_scale(domain); } make_y_scale(domain) { return this.#y_datatype.make_scale(domain); } }; /*DataTraits*/ function range_outer(lh, rh) { return [Math.min(lh[0], rh[0]), Math.max(lh[1], rh[1])]; } /*range_outer*/ /* a dataset driving a chart. * * PLAN: multiple lines in the same chart * - makeitso dataset can contain multiple data series * - give each series within a dataset its own index# * - each series computes its own min/max x/y values * - take union across series to get chart x/y range * - new class Dataset */ class Dataseries { /* normalizing transformation for event objects. * use to produce events {.x_value, .y_value} + key suitable for Map */ #data_traits = null; //new DataTraits(); /* .dataset_map :: string -> {key value pair} * must use string as keys, since Map uses object identity if key is Object */ #dataset_map = new Map(); /* vector of key-value pairs, in increasing x-axis order */ #dataset_v = []; /* min,max value of dataset[i].x_value */ #dset_min_x = null; #dset_max_x = null; /* min,max value of dataset[i].y_value */ #dset_min_y = null; #dset_max_y = null; #max_key = 0; constructor(data_traits) { this.#data_traits = data_traits; this.recalc_minmax(); } data_traits() { return this.#data_traits; } dataset_v() { return this.#dataset_v; } x_range() { return [this.#dset_min_x, this.#dset_max_x]; } y_range() { return [this.#dset_min_y, this.#dset_max_y]; } /* data_ev must have attributes consistent with what .#data_traits expects */ update_dataset(data_ev) { //console.log("Dataseries.update_dataset: data_ev=", data_ev); let x = this.#data_traits.x_value(data_ev); let y = this.#data_traits.y_value(data_ev); /* using this key to recognize + suppress duplicate points * (e.g. if browser winds up sending multiple snapshot requests * for the same dataset) */ let mapkey = this.#data_traits.mapkey(data_ev); //console.log("Dataseries.update_dataset: x=", x, ", y=", y, ", mapkey=", mapkey); /* in map must use time strings (not Dates) as keys */ if (this.#dataset_map.has(mapkey)) { /*skip -- assuming that source is immutable */; } else { /* kv.key is ordinal number identifying a datum. * not related to mapkey, except in so far as both work as datum ids */ let kv = {key: this.#max_key, x_value: x, y_value: y}; /* (reminder: js map keys need to be strings) */ this.#dataset_map.set(mapkey, kv); this.#dataset_v.push(kv); this.#max_key = this.#max_key+1; } } /*update_dataset*/ recalc_minmax() { if (this.#dataset_v.length == 0) { /* min,max value of dataset[i].x_value */ this.#dset_min_x = this.#data_traits.x_nominal(); this.#dset_max_x = this.#data_traits.x_nominal(); /* min,max value of dataset[i].y_value */ this.#dset_min_y = this.#data_traits.y_nominal(); this.#dset_max_y = this.#data_traits.y_nominal(); } else { /* min,max value of dataset[i].x_value */ this.#dset_min_x = d3.min(this.#dataset_v, (d) => { return d.x_value; }); this.#dset_max_x = d3.max(this.#dataset_v, (d) => { return d.x_value; }); /* min,max value of dataset[i].y_value */ this.#dset_min_y = d3.min(this.#dataset_v, (d) => { return d.y_value; }); this.#dset_max_y = d3.max(this.#dataset_v, (d) => { return d.y_value; }); } } /*recalc_minmax*/ /* note: caller should invoke .range() before using for drawing */ make_x_scale(xrange) { return this.#data_traits.make_x_scale(xrange /*domain*/); } /*make_x_scale*/ /* note: caller should invoke .range() before using for drawing */ make_y_scale(yrange) { return this.#data_traits.make_y_scale(yrange /*domain*/); } /*make_y_scale*/ }; /*Dataseries*/ /* bundle multiple dataseries for charting * for now: can have multiple series, but they need to be driven * from the same native row storage */ class Dataset { #dataseries_v = []; /* min/max x-values across all members of .dataseries_v */ #outer_x_range = null; /* min/max y-values across all members of .dataseries_v */ #outer_y_range = null; constructor(data_traits_v) { for (let i=0, n=data_traits_v.length; i tag above in DOM sketch)*/ #chart_svg = null; constructor(w, h, pad) { this.#chart_w = w; this.#chart_h = h; this.#chart_pad = pad; } x_range() { return [this.#chart_pad, this.#chart_w - this.#chart_pad]; } /* note: inverting bc svg y-values increase towards bottom of screen; * we want y-values to increase towards top of screen */ y_range() { return [this.#chart_h - this.#chart_pad, this.#chart_pad]; } require_gui(parent_d3sel, dataset) { this.#require_x_scale(dataset); this.#require_y_scale(dataset); this.#require_linegen(); /*will use .chart_x_scale, .chart_y_scale */ this.#require_x_axis(); /*will use .chart_x_scale*/ this.#require_y_axis(); /*will use .chart_y_scale*/ this.#require_svg(parent_d3sel, dataset); } /* dom element id to use for the i'th dataseries in this chart */ series_html_id(i_dataseries) { return "pts-" + i_dataseries; } /* update chart for new dataset contents * * Require: * - .require_gui(_, dataset) has been called * - #of dataseries has not changed since last call to .require_svg() */ update_chart(dataset) { /* update d3 scales * (shared across all series bundled into this dataset */ this.#rescale_chart(dataset); for (let i=0, n=dataset.n_dataseries(); i { return this.#chart_x_scale(d.x_value); }) .y((d) => { return this.#chart_y_scale(d.y_value); })); } } /*require_linegen*/ #require_x_axis() { if (!this.#chart_x_axis) { this.#chart_x_axis = (d3 .axisBottom() .scale(this.#chart_x_scale) .ticks(10)); } } #require_y_axis() { if (!this.#chart_y_axis) { this.#chart_y_axis = (d3 .axisLeft() .scale(this.#chart_y_scale) .ticks(10)); } } #require_svg(parent_d3sel, dataset) { if (!this.#chart_svg) { this.#chart_svg = (parent_d3sel // .select("#uls") .append("svg") .attr("width", this.#chart_w) .attr("height", this.#chart_h)); /* svg group comprising x-axis */ this.#chart_svg.append("g") .attr("class", "xaxis") .attr("id", "x_axis") .attr("transform", this.#x_axis_translate_str()) .call(this.#chart_x_axis); /* svg group comprising y-axis */ this.#chart_svg.append("g") .attr("class", "yaxis") .attr("id", "y_axis") .attr("transform", this.#y_axis_translate_str()) .call(this.#chart_y_axis); for (let i=0, n=dataset.n_dataseries(); i (will attach svg element here) * +- * +- (d3 will draw x-axis inside, .chart_x_axis() draws) * +- (d3 will draw y-axis inside, .chart_y_axis() draws) * +- * +- (.chart_line_gen draws) */ class TimeseriesCtl extends Controller { #dataset_uri = ''; #dataset = null; #chart = new LineChart(500 /*w*/, 250 /*h*/, 50 /*pad*/); constructor(dataset_uri, data_traits_v) { super(); this.#dataset_uri = dataset_uri; this.#dataset = new Dataset(data_traits_v); } static rescale_dataset(dataset) { dataset.recalc_minmax(); } /*rescale_dataset*/ /* request dataseries snapshot from webserver; * update+draw graph when snapshot arrives * * NOTE: * 1. typical web docs (e.g. MDN) will advise using response.json(): * fetch(uri) * .then((response) => response.json()) * .then((data) => dostuffwith(data)) * * however, this has a flaw: standard json is missing special floating-point values (!!); * in particular it has no representation for nan/+inf/-inf * 2. we want to use the extended json standard 'json5'; * however need care since JSON5.parse() fails spuriously (at least JSON5/chrome asof 24sep2022) * if given a promise */ request() { fetch(this.#dataset_uri) .then((response) => response.text()) .then((text) => this.on_snapshot_text(text)); // .then((text) => JSON5.parse() // .then((data) => this.on_snapshot(data)); } /*request*/ /* update from snapshot json text */ on_snapshot_text(text) { const data = JSON5.parse(text); this.on_snapshot(data); } /*on_snapshot_text*/ /* update from snapshot * * .on_snapshot() => .#dataset => .on_dataset() */ on_snapshot(data) { //console.log("on_snapshot: data=", data); data.forEach((x, i) => { // REFACTORME if (x._name_ == "UpxEvent") { this.on_update(x); } else if (x._name_ == "KalmanFilterStateExt") { this.on_update(x); } else { console.log("unexpected json record x=", x); } }); this.on_dataset(this.#dataset); } /*on_snapshot*/ /* update from websocket * * .on_update() => .#dataset => .on_dataset() */ on_update(data_ev) { this.#dataset.update_dataset(data_ev); this.on_dataset(this.#dataset); } /*on_update*/ /* call after modifying .#dataset * * .on_dataset() =|=> .rescale_dataset() =|========> .chart_x_axis ===\ * | |========> .chart_x_axis =\ | * | | | * |=> .chart_svg.#x_axis <==========================/ | * |=> .chart_svg.#y_axis <============================/ */ on_dataset(dataset) { //console.log("on_dataset: dataset=", dataset); // update x-scale, y-scale TimeseriesCtl.rescale_dataset(dataset); this.#chart.update_chart(dataset); } /*on_dataset*/ /* e.g. * ctl.require_gui(d3.select("#uls")) * to build chart gui under DOM element with id="uls" */ require_gui(parent_d3sel) { this.#chart.require_gui(parent_d3sel, this.#dataset); } /*require_gui*/ }; /*TimeseriesCtl*/ /* controller for timeseries graph, from uri [/dyn/uls/snap] + [/ws/uls] */ var uls_ctl = false; var uls_ctl_enabled = true; if (uls_ctl_enabled) { uls_ctl = new TimeseriesCtl('/dyn/uls/snap', [new DataTraits([(ev) => ev.tm, "datetime"], [(ev) => ev.upx, "float"])]); uls_ctl.require_gui(d3.select("#uls")); uls_ctl.request(); } /* controller for timeseries graph, from uri [/dyn/kfs/snap] + [/ws/kfs] */ var kfs_ctl = false; var kfs_ctl_enabled = true; if (kfs_ctl_enabled) { kfs_ctl = new TimeseriesCtl('/dyn/kfs/snap', [new DataTraits([(ev) => ev.tk, "datetime"], [(ev) => { return ev.x[0]; }, "float"]), /* 2.sigma below estimate */ new DataTraits([(ev) => ev.tk, "datetime"], [(ev) => { return Math.max(0.0, ev.x[0] - 2.0 * Math.sqrt(ev.P[0][0])); }, "float"]), /* 2.sigma above estimate */ new DataTraits([(ev) => ev.tk, "datetime"], [(ev) => { return Math.min(1.0, ev.x[0] + 2.0 * Math.sqrt(ev.P[0][0])); }, "float"]) ]); kfs_ctl.require_gui(d3.select("#kfs")); kfs_ctl.request(); } let key_fn = ((d) => { return d.key; }); /* controller for volsurface graph (strike -> volatility), * from uri [/dyn/kf/snap] */ d3.select("#refresh") .on("click", function() { console.log("button[#refresh] clicked"); uls_ctl.request(); }); var srv_ws = null; function content_loaded_fn() { console.log("Hi Roly, DOM loaded"); /* use this url to create websocket to the server that delivered current webpage */ let ws_url = choose_ws_url("" /*url_suffix*/); console.log("ws_url: [", ws_url, "]"); srv_ws = new WebSocket(ws_url, "lws-minimal"); try { // srv_ws.onopen = () => { ... }; // srv_ws.onclose = () => { ... }; srv_ws.onmessage = (msg) => { /* msg has dozens of attributes, too many to list here * actual application message appears in the .data attribute * (as nested js string) */ //console.log("incoming ws msg: [", msg, "]"); let msgdata = JSON5.parse(msg.data); //console.log("msgdata: [", msgdata, "]"); let stream_name = msgdata.stream; let event = msgdata.event; if (stream_name == "/ws/uls") { if (uls_ctl) { uls_ctl.on_update(event); } } else if (stream_name == "/ws/kfs") { if (kfs_ctl) { kfs_ctl.on_update(event); } } else { console.log("unknown stream name [", stream_name, "]"); } }; } catch(excetpion) { asert("

Error: " + exception); } console.log("srv_ws state [", srv_ws.readyState, "]"); srv_ws.addEventListener('open', (event) => { console.log("srv_ws state [", srv_ws.readyState, "]"); if (uls_ctl) { srv_ws.send("{\"cmd\": \"subscribe\", \"stream\": \"/ws/uls\"} "); } if (kfs_ctl) { srv_ws.send("{\"cmd\": \"subscribe\", \"stream\": \"/ws/kfs\"} "); } //socket.send('Hello Server!'); }); } /*content_loaded_fn*/ document.addEventListener("DOMContentLoaded", content_loaded_fn, false); /* end ex_websock.js */