Skip to content
Snippets Groups Projects
Unverified Commit 2f164747 authored by Martyn Janes's avatar Martyn Janes Committed by GitHub
Browse files

Improved tooling for analysis and updated conflicts UI (#593)

* Improved tooling for analysis and updated conflicts UI

* :package: Update packr

* :package: Run packr2

* :recycle: Refactor analysis-dashboard FPC

* :rotating_light: Fix linter warnings

* :lipstick: Fix missing logo

* :arrow_up:

 Upgrade yarn dependencies

Co-authored-by: default avatarcapossele <angelocapossele@gmail.com>
parent 0ff0cb11
No related branches found
No related tags found
No related merge requests found
Showing
with 685 additions and 538 deletions
export interface INeighbors {
in: Set<string>;
out: Set<string>;
}
\ No newline at end of file
export interface IStoredConflict {
conflictID: string;
nodeOpinions: { [id: string]: number[] };
lastUpdated: number;
likes?: number;
}
\ No newline at end of file
export interface IAddNodeMessage {
id: string;
}
import { IVoteContext } from "./IVoteContext";
export interface IConflict {
nodesview: { [id: string]: IVoteContext };
modified: number;
}
export interface IConnectNodesMessage {
source: string;
target: string;
}
\ No newline at end of file
export interface IDisconnectNodesMessage {
source: string;
target: string;
}
\ No newline at end of file
import { IConflict } from "./IConflict";
export interface IFPCMessage {
conflictset: { [id: string]: IConflict };
}
export interface IRemoveNodeMessage {
id: string;
}
export interface IVoteContext {
nodeid: string;
rounds: number;
opinions: number[];
status: number;
}
export enum Opinion {
like = 1,
dislike = 2
}
\ No newline at end of file
import { WSMsgType } from "./wsMsgType";
export interface WSMessage {
type: WSMsgType;
data: unknown;
}
export enum WSMsgType {
ping = 0,
fpc = 1,
addNode = 2,
removeNode = 3,
connectNodes = 4,
disconnectNodes = 5
}
import { IFPCMessage } from "../models/messages/IFPCMessage";
import { IAddNodeMessage } from "../models/messages/IAddNodeMessage";
import { IRemoveNodeMessage } from "../models/messages/IRemoveNodeMessage";
import { IConnectNodesMessage } from "../models/messages/IConnectNodesMessage";
import { IDisconnectNodesMessage } from "../models/messages/IDisconnectNodesMessage";
import { WSMsgType } from "../models/ws/wsMsgType";
import { WSMessage } from "../models/ws/IWSMsg";
type DataHandler<T> = (data: T) => void;
const handlers: { [id in WSMsgType]?: DataHandler<unknown> } = {};
export function registerHandler(msgTypeID: WSMsgType.fpc, handler: DataHandler<IFPCMessage>);
export function registerHandler(msgTypeID: WSMsgType.addNode, handler: DataHandler<IAddNodeMessage>);
export function registerHandler(msgTypeID: WSMsgType.removeNode, handler: DataHandler<IRemoveNodeMessage>);
export function registerHandler(msgTypeID: WSMsgType.connectNodes, handler: DataHandler<IConnectNodesMessage>);
export function registerHandler(msgTypeID: WSMsgType.disconnectNodes, handler: DataHandler<IDisconnectNodesMessage>);
export function registerHandler<T>(msgTypeID: number, handler: DataHandler<T>): void {
handlers[msgTypeID] = handler;
}
export function unregisterHandler(msgTypeID: number): void {
delete handlers[msgTypeID];
}
export function connectWebSocket(
path: string,
onOpen: () => void,
onClose: () => void,
onError: () => void): void {
const loc = window.location;
let uri = "ws:";
if (loc.protocol === "https:") {
uri = "wss:";
}
uri += "//" + loc.host + path;
const ws = new WebSocket(uri);
ws.onopen = onOpen;
ws.onclose = onClose;
ws.onerror = onError;
ws.onmessage = (e) => {
const msg: WSMessage = JSON.parse(e.data) as WSMessage;
// Just a ping, do nothing
if (msg.type === WSMsgType.ping) {
return;
}
const handler = handlers[msg.type];
if (handler) {
handler(msg.data);
}
};
}
import { registerHandler, WSMsgType, unregisterHandler } from "app/misc/WS";
import { action, computed, observable } from "mobx";
enum Opinion {
Like = 1,
Dislike
}
class VoteContext {
nodeid: string;
rounds: number;
opinions: number[];
outcome: number;
}
class Conflict {
nodesview: Map<string, VoteContext>
}
export class FPCMessage {
conflictset: Map<string, Conflict>
}
import { IStoredConflict } from "../models/IStoredConflict";
import { IFPCMessage } from "../models/messages/IFPCMessage";
import { Opinion } from "../models/opinion";
import { WSMsgType } from "../models/ws/wsMsgType";
import { registerHandler, unregisterHandler } from "../services/WS";
export class FPCStore {
@observable msg: FPCMessage = null;
@observable
private conflicts: IStoredConflict[] = [];
@observable conflicts: {
conflictID: string;
nodeOpinions: { nodeID: string; opinion: number }[];
lastUpdated: number;
likes?: number;
}[] = [];
@observable
private currentConflict: string = "";
@observable currentConflict = "";
private timerId: number;
timerId: NodeJS.Timer;
constructor() {
@computed
public get nodeConflictGrid(): { [id: string]: number[] } | undefined {
if (!this.currentConflict) {
return undefined;
}
start() {
registerHandler(WSMsgType.FPC, this.addLiveFeed);
this.timerId = setInterval(() => this.updateConflictStates(), 2000);
const currentConflict = this.conflicts.find(c => c.conflictID === this.currentConflict);
if (!currentConflict) {
return undefined;
}
return currentConflict.nodeOpinions;
}
stop() {
unregisterHandler(WSMsgType.FPC);
clearInterval(this.timerId);
@computed
public get conflictGrid(): IStoredConflict[] {
return this.conflicts.filter(c => c.likes !== undefined);
}
@action
updateCurrentConflict = (id: string) => {
public updateCurrentConflict(id: string): void {
this.currentConflict = id;
}
@action
addLiveFeed = (msg: FPCMessage) => {
let conflictIDs = Object.keys(msg.conflictset);
if (!conflictIDs) return;
for (const conflictID of conflictIDs) {
let nodeIDs = Object.keys(msg.conflictset[conflictID].nodesview);
for (const nodeID of nodeIDs) {
let voteContext = msg.conflictset[conflictID].nodesview[nodeID];
let latestOpinion = voteContext.opinions[voteContext.opinions.length - 1];
private addLiveFeed(msg: IFPCMessage): void {
for (const conflictID in msg.conflictset) {
for (const nodeID in msg.conflictset[conflictID].nodesview) {
const voteContext = msg.conflictset[conflictID].nodesview[nodeID];
let conflict = this.conflicts.find(c => c.conflictID === conflictID);
if (!conflict) {
conflict = {
conflictID,
lastUpdated: Date.now(),
nodeOpinions: []
nodeOpinions: {}
};
this.conflicts.push(conflict);
} else {
conflict.lastUpdated = Date.now();
}
const nodeOpinionIndex = conflict.nodeOpinions.findIndex(no => no.nodeID === nodeID);
if (nodeOpinionIndex >= 0) {
conflict.nodeOpinions[nodeOpinionIndex].opinion = latestOpinion;
} else {
conflict.nodeOpinions.push({
nodeID,
opinion: latestOpinion
});
if (!(nodeID in conflict.nodeOpinions)) {
conflict.nodeOpinions[nodeID] = [];
}
conflict.nodeOpinions[nodeID] = voteContext.opinions;
this.updateConflictState(conflict);
}
......@@ -92,9 +65,9 @@ export class FPCStore {
}
@action
updateConflictStates() {
for (let i = 0; i < this.conflicts.length; i++) {
this.updateConflictState(this.conflicts[i]);
private updateConflictStates(): void {
for (const conflict of this.conflicts) {
this.updateConflictState(conflict);
}
const resolvedConflictIds = this.conflicts.filter(c =>
......@@ -103,26 +76,30 @@ export class FPCStore {
).map(c => c.conflictID);
for (const conflictID of resolvedConflictIds) {
this.conflicts = this.conflicts.filter(c => c.conflictID !== conflictID)
this.conflicts = this.conflicts.filter(c => c.conflictID !== conflictID);
}
}
@action
updateConflictState(conflict) {
conflict.likes = conflict.nodeOpinions.filter((nodeOpinion) => nodeOpinion.opinion === Opinion.Like).length;
private updateConflictState(conflict: IStoredConflict): void {
let likes = 0;
for (const nodeConflict in conflict.nodeOpinions) {
if (conflict.nodeOpinions[nodeConflict].length > 0 &&
conflict.nodeOpinions[nodeConflict][conflict.nodeOpinions[nodeConflict].length - 1] === Opinion.like) {
likes++;
}
}
conflict.likes = likes;
}
@computed
get nodeConflictGrid() {
if (!this.currentConflict) return;
const currentConflict = this.conflicts.find(c => c.conflictID === this.currentConflict);
if (!currentConflict) return;
return currentConflict.nodeOpinions;
public start(): void {
registerHandler(WSMsgType.fpc, (msg) => this.addLiveFeed(msg));
this.timerId = setInterval(() => this.updateConflictStates(), 2000);
}
@computed
get conflictGrid() {
return this.conflicts.filter(c => c.likes !== undefined);
public stop(): void {
unregisterHandler(WSMsgType.fpc);
clearInterval(this.timerId);
}
}
......
import Viva from "vivagraphjs";
// Next comes the hard part - implementation of API for custom shader
// program, used by webgl renderer:
export function buildCircleNodeShader(): WebGLProgram {
// For each primitive we need 4 attributes: x, y, color and size.
const ATTRIBUTES_PER_PRIMITIVE = 4,
nodesFS = [
"precision mediump float;",
"varying vec4 color;",
"void main(void) {",
" if ((gl_PointCoord.x - 0.5) * (gl_PointCoord.x - 0.5) + (gl_PointCoord.y - 0.5) * (gl_PointCoord.y - 0.5) < 0.25) {",
" gl_FragColor = color;",
" } else {",
" gl_FragColor = vec4(0);",
" }",
"}"].join("\n"),
nodesVS = [
"attribute vec2 a_vertexPos;",
// Pack color and size into vector. First elemnt is color, second - size.
// Since it's floating point we can only use 24 bit to pack colors...
// thus alpha channel is dropped, and is always assumed to be 1.
"attribute vec2 a_customAttributes;",
"uniform vec2 u_screenSize;",
"uniform mat4 u_transform;",
"varying vec4 color;",
"void main(void) {",
" gl_Position = u_transform * vec4(a_vertexPos/u_screenSize, 0, 1);",
" gl_PointSize = a_customAttributes[1] * u_transform[0][0];",
" float c = a_customAttributes[0];",
" color.b = mod(c, 256.0); c = floor(c/256.0);",
" color.g = mod(c, 256.0); c = floor(c/256.0);",
" color.r = mod(c, 256.0); c = floor(c/256.0); color /= 255.0;",
" color.a = 1.0;",
"}"].join("\n");
let program,
gl: WebGLRenderingContext,
buffer,
locations: Viva.Graph.ILocation,
webglUtils: Viva.Graph.IWebGL,
nodes = new Float32Array(64),
nodesCount: number = 0,
canvasWidth: number,
canvasHeight: number,
transform: Float32List,
isCanvasDirty: boolean;
return {
/**
* Called by webgl renderer to load the shader into gl context.
*/
load: (glContext: WebGLRenderingContext) => {
gl = glContext;
webglUtils = Viva.Graph.webgl(glContext);
program = webglUtils.createProgram(nodesVS, nodesFS);
gl.useProgram(program);
locations = webglUtils.getLocations(program, ["a_vertexPos", "a_customAttributes", "u_screenSize", "u_transform"]);
gl.enableVertexAttribArray(locations.vertexPos);
gl.enableVertexAttribArray(locations.customAttributes);
buffer = gl.createBuffer();
},
/**
* Called by webgl renderer to update node position in the buffer array
*
* @param nodeUI - data model for the rendered node (WebGLCircle in this case)
* @param pos - {x, y} coordinates of the node.
*/
position: (nodeUI: { color: number; size: number; id: number }, pos: { x: number; y: number }) => {
const idx = nodeUI.id;
nodes[idx * ATTRIBUTES_PER_PRIMITIVE] = pos.x;
nodes[idx * ATTRIBUTES_PER_PRIMITIVE + 1] = -pos.y;
nodes[idx * ATTRIBUTES_PER_PRIMITIVE + 2] = nodeUI.color;
nodes[idx * ATTRIBUTES_PER_PRIMITIVE + 3] = nodeUI.size;
},
/**
* Request from webgl renderer to actually draw our stuff into the
* gl context. This is the core of our shader.
*/
render: () => {
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, nodes, gl.DYNAMIC_DRAW);
if (isCanvasDirty) {
isCanvasDirty = false;
gl.uniformMatrix4fv(locations.transform, false, transform);
gl.uniform2f(locations.screenSize, canvasWidth, canvasHeight);
}
gl.vertexAttribPointer(locations.vertexPos, 2, gl.FLOAT, false, ATTRIBUTES_PER_PRIMITIVE * Float32Array.BYTES_PER_ELEMENT, 0);
gl.vertexAttribPointer(locations.customAttributes, 2, gl.FLOAT, false, ATTRIBUTES_PER_PRIMITIVE * Float32Array.BYTES_PER_ELEMENT, 2 * 4);
gl.drawArrays(gl.POINTS, 0, nodesCount);
},
/**
* Called by webgl renderer when user scales/pans the canvas with nodes.
*/
updateTransform: (newTransform: Float32List) => {
transform = newTransform;
isCanvasDirty = true;
},
/**
* Called by webgl renderer when user resizes the canvas with nodes.
*/
updateSize: (newCanvasWidth: number, newCanvasHeight: number) => {
canvasWidth = newCanvasWidth;
canvasHeight = newCanvasHeight;
isCanvasDirty = true;
},
/**
* Called by webgl renderer to notify us that the new node was created in the graph
*/
createNode: () => {
nodes = webglUtils.extendArray(nodes, nodesCount, ATTRIBUTES_PER_PRIMITIVE);
nodesCount += 1;
},
/**
* Called by webgl renderer to notify us that the node was removed from the graph
*/
removeNode: (node: { id: number }) => {
if (nodesCount > 0) {
nodesCount -= 1;
}
if (node.id < nodesCount && nodesCount > 0) {
// we do not really delete anything from the buffer.
// Instead we swap deleted node with the "last" node in the
// buffer and decrease marker of the "last" node. Gives nice O(1)
// performance, but make code slightly harder than it could be:
webglUtils.copyArrayPart(nodes, node.id * ATTRIBUTES_PER_PRIMITIVE, nodesCount * ATTRIBUTES_PER_PRIMITIVE, ATTRIBUTES_PER_PRIMITIVE);
}
},
/**
* This method is called by webgl renderer when it changes parts of its
* buffers. We don't use it here, but it's needed by API (see the comment
* in the removeNode() method)
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
replaceProperties: function () {
}
};
}
\ No newline at end of file
export function parseColor(color: number | string): number {
if (typeof color === "number") {
return color;
}
let parsedColor: number = 0;
if (typeof color === "string") {
if (color.length === 4) {
// #rgb, duplicate each letter except first #.
color = color.replace(/([^#])/g, "$1$1");
}
if (color.length === 9) {
// #rrggbbaa
parsedColor = parseInt(color.substr(1), 16);
} else if (color.length === 7) {
// or #rrggbb.
parsedColor = parseInt(color.substr(1), 16) << 8 | 0xff;
} else {
throw new Error(`Color expected in hex format with preceding "#". E.g. #00ff00. Got value: ${color}`);
}
}
return parsedColor;
}
import App from 'app/App';
import AutopeeringStore from "app/stores/AutopeeringStore";
import FPCStore from "app/stores/FPCStore";
import { Provider } from 'mobx-react';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Route } from 'react-router';
import { BrowserRouter as Router } from 'react-router-dom';
import App from "./app/App";
import { AutopeeringStore } from "./app/stores/AutopeeringStore";
import FPCStore from "./app/stores/FPCStore";
import { Provider } from "mobx-react";
import React from "react";
import ReactDOM from "react-dom";
import { Route } from "react-router";
import { BrowserRouter as Router } from "react-router-dom";
import "./main.scss";
const fpcStore = new FPCStore();
const autopeeringStore = new AutopeeringStore()
const autopeeringStore = new AutopeeringStore();
const stores = {
"fpcStore": fpcStore,
"autopeeringStore": autopeeringStore,
"autopeeringStore": autopeeringStore
};
// render react DOM
......@@ -22,5 +22,5 @@ ReactDOM.render(
<Route component={(props) => <App {...props} />} />
</Router>
</Provider>,
document.getElementById('root')
document.getElementById("root")
);
\ No newline at end of file
......@@ -36,3 +36,27 @@
.margin-b-m {
margin-bottom: 32px;
}
.margin-l-t {
margin-left: 10px;
}
.margin-l-s {
margin-left: 20px;
}
.margin-l-m {
margin-left: 32px;
}
.margin-r-t {
margin-right: 10px;
}
.margin-r-s {
margin-right: 20px;
}
.margin-r-m {
margin-right: 32px;
}
/** Global definitions for developement **/
// for style loader
declare module '*.css' {
declare module "*.css" {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const styles: any;
export = styles;
}
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment