Adds a distribution drawer to widedomain.
Things to note: - The code has comments. I feel protective of these comments, and feel that they help structure the code and will help me out when I come I come back to this code a couple of weeks or months from now. - Originally based on code by Evan Ward (probability.dev). See also: observablehq.com/@nunosempere/distribution-drawer To do, in order of importance: - Add the ability to change the upper and lower boundaries. - Make the drawings relative to the canvas, not to the screen. - Add other features from probability.dev Cool things yet to be done: - Make it so that one can input a guesstimate function, and then draw on it. To do this, use the Convert.xyShapeToCanvasShape and modify the Draw.initial distribution function slightly. - Maybe reach out to Metaculus to see if they want to use this somewhere?
This commit is contained in:
parent
0d4c71190d
commit
ff5b26d865
|
@ -19,7 +19,11 @@
|
||||||
"subdirs": true
|
"subdirs": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"bsc-flags": ["-bs-super-errors", "-bs-no-version-header", "-bs-g"],
|
"bsc-flags": [
|
||||||
|
"-bs-super-errors",
|
||||||
|
"-bs-no-version-header",
|
||||||
|
"-bs-g"
|
||||||
|
],
|
||||||
"package-specs": [
|
"package-specs": [
|
||||||
{
|
{
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
|
@ -38,8 +42,12 @@
|
||||||
"bs-css",
|
"bs-css",
|
||||||
"rationale",
|
"rationale",
|
||||||
"bs-moment",
|
"bs-moment",
|
||||||
"reschema"
|
"reschema",
|
||||||
|
"bs-webapi",
|
||||||
|
"bs-fetch"
|
||||||
],
|
],
|
||||||
"refmt": 3,
|
"refmt": 3,
|
||||||
"ppx-flags": ["lenses-ppx/ppx"]
|
"ppx-flags": [
|
||||||
}
|
"lenses-ppx/ppx"
|
||||||
|
]
|
||||||
|
}
|
|
@ -36,9 +36,11 @@
|
||||||
"binary-search-tree": "0.2.6",
|
"binary-search-tree": "0.2.6",
|
||||||
"bs-ant-design-alt": "2.0.0-alpha.33",
|
"bs-ant-design-alt": "2.0.0-alpha.33",
|
||||||
"bs-css": "11.0.0",
|
"bs-css": "11.0.0",
|
||||||
|
"bs-fetch": "^0.5.2",
|
||||||
"bs-moment": "0.4.4",
|
"bs-moment": "0.4.4",
|
||||||
"bs-platform": "7.2.2",
|
"bs-platform": "^7.2.2",
|
||||||
"bs-reform": "9.7.1",
|
"bs-reform": "9.7.1",
|
||||||
|
"bs-webapi": "^0.15.9",
|
||||||
"bsb-js": "1.1.7",
|
"bsb-js": "1.1.7",
|
||||||
"d3": "5.15.0",
|
"d3": "5.15.0",
|
||||||
"gh-pages": "2.2.0",
|
"gh-pages": "2.2.0",
|
||||||
|
|
|
@ -3,6 +3,7 @@ type route =
|
||||||
| DistBuilder
|
| DistBuilder
|
||||||
| DistBuilder2
|
| DistBuilder2
|
||||||
| DistBuilder3
|
| DistBuilder3
|
||||||
|
| Drawer
|
||||||
| Home
|
| Home
|
||||||
| NotFound;
|
| NotFound;
|
||||||
|
|
||||||
|
@ -12,6 +13,7 @@ let routeToPath = route =>
|
||||||
| DistBuilder => "/dist-builder"
|
| DistBuilder => "/dist-builder"
|
||||||
| DistBuilder2 => "/dist-builder2"
|
| DistBuilder2 => "/dist-builder2"
|
||||||
| DistBuilder3 => "/dist-builder3"
|
| DistBuilder3 => "/dist-builder3"
|
||||||
|
| Drawer => "/drawer"
|
||||||
| Home => "/"
|
| Home => "/"
|
||||||
| _ => "/"
|
| _ => "/"
|
||||||
};
|
};
|
||||||
|
@ -79,6 +81,10 @@ module Menu = {
|
||||||
<Item href={routeToPath(DistBuilder3)} key="dist-builder-3">
|
<Item href={routeToPath(DistBuilder3)} key="dist-builder-3">
|
||||||
{"Dist Builder 3" |> R.ste}
|
{"Dist Builder 3" |> R.ste}
|
||||||
</Item>
|
</Item>
|
||||||
|
<Item href={routeToPath(Drawer)} key="drawer">
|
||||||
|
{"Drawer" |> R.ste}
|
||||||
|
</Item>
|
||||||
|
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -93,6 +99,7 @@ let make = () => {
|
||||||
| ["dist-builder"] => DistBuilder
|
| ["dist-builder"] => DistBuilder
|
||||||
| ["dist-builder2"] => DistBuilder2
|
| ["dist-builder2"] => DistBuilder2
|
||||||
| ["dist-builder3"] => DistBuilder3
|
| ["dist-builder3"] => DistBuilder3
|
||||||
|
| ["drawer"] => Drawer
|
||||||
| [] => Home
|
| [] => Home
|
||||||
| _ => NotFound
|
| _ => NotFound
|
||||||
};
|
};
|
||||||
|
@ -108,6 +115,7 @@ let make = () => {
|
||||||
| DistBuilder => <DistBuilder />
|
| DistBuilder => <DistBuilder />
|
||||||
| DistBuilder2 => <DistBuilder2 />
|
| DistBuilder2 => <DistBuilder2 />
|
||||||
| DistBuilder3 => <DistBuilder3 />
|
| DistBuilder3 => <DistBuilder3 />
|
||||||
|
| Drawer => <Drawer />
|
||||||
| Home => <Home />
|
| Home => <Home />
|
||||||
| _ => <div> {"Page is not found" |> R.ste} </div>
|
| _ => <div> {"Page is not found" |> R.ste} </div>
|
||||||
}}
|
}}
|
||||||
|
|
814
src/components/Drawer.re
Normal file
814
src/components/Drawer.re
Normal file
|
@ -0,0 +1,814 @@
|
||||||
|
module Types = {
|
||||||
|
|
||||||
|
type rectangle = {
|
||||||
|
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect
|
||||||
|
left: int,
|
||||||
|
top: int,
|
||||||
|
right: int,
|
||||||
|
bottom: int,
|
||||||
|
x: int,
|
||||||
|
y: int,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
};
|
||||||
|
|
||||||
|
type webapi = Webapi.Canvas.Canvas2d.t;
|
||||||
|
|
||||||
|
type xyShape = DistTypes.xyShape; /* {
|
||||||
|
xs: array(float),
|
||||||
|
ys: array(float),
|
||||||
|
};*/
|
||||||
|
|
||||||
|
type continuousShape = DistTypes.continuousShape; /*{
|
||||||
|
xyShape,
|
||||||
|
interpolation: [ | `Stepwise | `Linear],
|
||||||
|
};*/
|
||||||
|
|
||||||
|
type canvasPoint = {
|
||||||
|
w: float,
|
||||||
|
h: float,
|
||||||
|
};
|
||||||
|
|
||||||
|
type canvasShape = {
|
||||||
|
ws: array(float),
|
||||||
|
hs: array(float),
|
||||||
|
xValues: array(float),
|
||||||
|
};
|
||||||
|
|
||||||
|
type formElements = {
|
||||||
|
measurableId: string,
|
||||||
|
token: string,
|
||||||
|
comment: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
type canvasState = {
|
||||||
|
isMouseDown: bool,
|
||||||
|
lastMousePosition: option(canvasPoint),
|
||||||
|
canvasShape: option(canvasShape),
|
||||||
|
readyToRender: bool,
|
||||||
|
formElements,
|
||||||
|
hasJustBeenSent: bool,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module CanvasContext = {
|
||||||
|
type t = Types.webapi;
|
||||||
|
|
||||||
|
/* Externals */
|
||||||
|
[@bs.send]
|
||||||
|
external getBoundingClientRect: Dom.element => Types.rectangle =
|
||||||
|
"getBoundingClientRect";
|
||||||
|
[@bs.send] external setLineDash: (t, array(int)) => unit = "setLineDash";
|
||||||
|
|
||||||
|
/* Webapi functions */
|
||||||
|
// Ref: https://github.com/reasonml-community/bs-webapi-incubator/blob/master/src/Webapi/Webapi__Canvas/Webapi__Canvas__Canvas2d.re
|
||||||
|
let getContext2d: Dom.element => t = Webapi.Canvas.CanvasElement.getContext2d;
|
||||||
|
module Canvas2d = Webapi.Canvas.Canvas2d;
|
||||||
|
let clearRect = Canvas2d.clearRect;
|
||||||
|
let setFillStyle = Canvas2d.setFillStyle;
|
||||||
|
let fillRect = Canvas2d.fillRect;
|
||||||
|
let beginPath = Canvas2d.beginPath;
|
||||||
|
let closePath = Canvas2d.closePath;
|
||||||
|
let setStrokeStyle = Canvas2d.setStrokeStyle;
|
||||||
|
let lineWidth = Canvas2d.lineWidth;
|
||||||
|
let moveTo = Canvas2d.moveTo;
|
||||||
|
let lineTo = Canvas2d.lineTo;
|
||||||
|
let stroke = Canvas2d.stroke;
|
||||||
|
let font = Canvas2d.font;
|
||||||
|
let textAlign = Canvas2d.textAlign;
|
||||||
|
let strokeText = Canvas2d.strokeText;
|
||||||
|
let fillText = Canvas2d.fillText;
|
||||||
|
|
||||||
|
/* Padding */
|
||||||
|
let paddingRatioX = 0.9;
|
||||||
|
let paddingRatioY = 0.9;
|
||||||
|
|
||||||
|
let paddingFactorX = (rectangleWidth: int) =>
|
||||||
|
(1. -. paddingRatioX) *. float_of_int(rectangleWidth) /. 2.0;
|
||||||
|
let paddingFactorY = (rectangleHeight: int) =>
|
||||||
|
(1. -. paddingRatioY) *. float_of_int(rectangleHeight) /. 2.0;
|
||||||
|
|
||||||
|
let translatePointToInside = (canvasElement: Dom.element) => {
|
||||||
|
let rectangle: Types.rectangle = getBoundingClientRect(canvasElement);
|
||||||
|
let translate = (p: Types.canvasPoint): Types.canvasPoint => {
|
||||||
|
let w = p.w -. float_of_int(rectangle.x);
|
||||||
|
let h = p.h -. float_of_int(rectangle.y);
|
||||||
|
{w, h};
|
||||||
|
};
|
||||||
|
translate;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module Convert = {
|
||||||
|
/*
|
||||||
|
- In this module, the fundamental unit for the canvas shape is the distance vector from the (0,0) point at the upper leftmost corner of the screen.
|
||||||
|
- For some drawing functions, this is instead from the (0,0) point at the upper leftmost corner of the canvas element. This is irrelevant in this module.
|
||||||
|
- The fundamental unit for a probability distribution is an x coordinate and its corresponding y probability density
|
||||||
|
*/
|
||||||
|
|
||||||
|
let xyShapeToCanvasShape =
|
||||||
|
(~xyShape: Types.xyShape, ~canvasElement: Dom.element) => {
|
||||||
|
let xs = xyShape.xs;
|
||||||
|
let ys = xyShape.ys;
|
||||||
|
let rectangle: Types.rectangle = CanvasContext.getBoundingClientRect(canvasElement);
|
||||||
|
let lengthX = E.A.length(xs);
|
||||||
|
|
||||||
|
let minX = xs[0];
|
||||||
|
let maxX = xs[lengthX - 1];
|
||||||
|
let ratioXs =
|
||||||
|
float_of_int(rectangle.width) *. CanvasContext.paddingRatioX /. (maxX -. minX);
|
||||||
|
let ws =
|
||||||
|
E.A.fmap(
|
||||||
|
x =>
|
||||||
|
(x -. minX)
|
||||||
|
*. ratioXs
|
||||||
|
+. float_of_int(rectangle.left)
|
||||||
|
+. (1. -. CanvasContext.paddingRatioX)
|
||||||
|
*. float_of_int(rectangle.width)
|
||||||
|
/. 2.0,
|
||||||
|
xs,
|
||||||
|
);
|
||||||
|
|
||||||
|
let minY = 0.;
|
||||||
|
let maxY = E.A.reduce(ys, 0., (x, y) => x > y ? x : y);
|
||||||
|
let ratioYs = float_of_int(rectangle.height) *. CanvasContext.paddingRatioY /. (maxY -. minY);
|
||||||
|
let hs =
|
||||||
|
E.A.fmap(
|
||||||
|
y =>
|
||||||
|
float_of_int(rectangle.bottom)
|
||||||
|
-. y
|
||||||
|
*. ratioYs
|
||||||
|
-. CanvasContext.paddingFactorY(rectangle.height),
|
||||||
|
ys,
|
||||||
|
);
|
||||||
|
|
||||||
|
let canvasShape: Types.canvasShape = {ws, hs, xValues: xs};
|
||||||
|
canvasShape;
|
||||||
|
};
|
||||||
|
|
||||||
|
let canvasShapeToContinuousShape =
|
||||||
|
(~canvasShape: Types.canvasShape, ~canvasElement: Dom.element)
|
||||||
|
: Types.continuousShape => {
|
||||||
|
let xs = canvasShape.xValues;
|
||||||
|
let hs = canvasShape.hs;
|
||||||
|
let rectangle: Types.rectangle = CanvasContext.getBoundingClientRect(canvasElement);
|
||||||
|
let bottom = float_of_int(rectangle.bottom);
|
||||||
|
|
||||||
|
let ysRelative =
|
||||||
|
E.A.fmap(h => bottom -. h +. CanvasContext.paddingFactorY(rectangle.height), hs);
|
||||||
|
let xyShape: Types.xyShape = {xs, ys: ysRelative};
|
||||||
|
let continuousShape: Types.continuousShape = {
|
||||||
|
xyShape,
|
||||||
|
interpolation: `Linear,
|
||||||
|
};
|
||||||
|
let integral = XYShape.Analysis.integrateContinuousShape(continuousShape);
|
||||||
|
let ys = E.A.fmap(y => y /. integral, ysRelative);
|
||||||
|
let continuousShape: Types.continuousShape = {
|
||||||
|
xyShape: {
|
||||||
|
xs,
|
||||||
|
ys,
|
||||||
|
},
|
||||||
|
interpolation: `Linear,
|
||||||
|
};
|
||||||
|
continuousShape;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Misc helper functions */
|
||||||
|
let log2 = x => log(x) /. log(2.0);
|
||||||
|
let findClosestInOrderedArrayDangerously = (x: float, xs: array(float)) => {
|
||||||
|
let l = Array.length(xs);
|
||||||
|
let a = ref(0);
|
||||||
|
let b = ref(l - 1);
|
||||||
|
let numSteps = int_of_float(log2(float_of_int(l))) + 1;
|
||||||
|
for (_ in 0 to numSteps) {
|
||||||
|
let c = (a^ + b^) / 2;
|
||||||
|
xs[c] > x ? b := c : a := c;
|
||||||
|
};
|
||||||
|
b^;
|
||||||
|
};
|
||||||
|
let getPoint = (canvasShape: Types.canvasShape, n: int): Types.canvasPoint => {
|
||||||
|
let point: Types.canvasPoint = {
|
||||||
|
w: canvasShape.ws[n],
|
||||||
|
h: canvasShape.hs[n],
|
||||||
|
};
|
||||||
|
point;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module Draw = {
|
||||||
|
|
||||||
|
let line =
|
||||||
|
(
|
||||||
|
canvasElement: Dom.element,
|
||||||
|
~point0: Types.canvasPoint,
|
||||||
|
~point1: Types.canvasPoint,
|
||||||
|
)
|
||||||
|
: unit => {
|
||||||
|
let translator = CanvasContext.translatePointToInside(canvasElement);
|
||||||
|
let point0 = translator(point0);
|
||||||
|
let point1 = translator(point1);
|
||||||
|
|
||||||
|
let context = CanvasContext.getContext2d(canvasElement);
|
||||||
|
CanvasContext.beginPath(context);
|
||||||
|
CanvasContext.moveTo(context, ~x=point0.w, ~y=point0.h);
|
||||||
|
CanvasContext.lineTo(context, ~x=point1.w, ~y=point1.h);
|
||||||
|
CanvasContext.stroke(context);
|
||||||
|
};
|
||||||
|
|
||||||
|
let canvasPlot =
|
||||||
|
(canvasElement: Dom.element, canvasShape: Types.canvasShape) => {
|
||||||
|
let context = CanvasContext.getContext2d(canvasElement);
|
||||||
|
let rectangle: Types.rectangle = CanvasContext.getBoundingClientRect(canvasElement);
|
||||||
|
|
||||||
|
/* Some useful reference points */
|
||||||
|
let paddingFactorX = CanvasContext.paddingFactorX(rectangle.width);
|
||||||
|
let paddingFactorY = CanvasContext.paddingFactorX(rectangle.height);
|
||||||
|
|
||||||
|
let p00: Types.canvasPoint = {
|
||||||
|
w: float_of_int(rectangle.left) +. paddingFactorX,
|
||||||
|
h: float_of_int(rectangle.bottom) -. paddingFactorY,
|
||||||
|
};
|
||||||
|
let p01: Types.canvasPoint = {
|
||||||
|
w: float_of_int(rectangle.left) +. paddingFactorX,
|
||||||
|
h: float_of_int(rectangle.top) +. paddingFactorY,
|
||||||
|
};
|
||||||
|
let p10: Types.canvasPoint = {
|
||||||
|
w: float_of_int(rectangle.right) -. paddingFactorX,
|
||||||
|
h: float_of_int(rectangle.bottom) -. paddingFactorY,
|
||||||
|
};
|
||||||
|
let p11: Types.canvasPoint = {
|
||||||
|
w: float_of_int(rectangle.right) -. paddingFactorX,
|
||||||
|
h: float_of_int(rectangle.top) +. paddingFactorY,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Clear the canvas with new white sheet */
|
||||||
|
CanvasContext.clearRect(
|
||||||
|
context,
|
||||||
|
~x=0.,
|
||||||
|
~y=0.,
|
||||||
|
~w=float_of_int(rectangle.width),
|
||||||
|
~h=float_of_int(rectangle.height),
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Draw a line between every two adjacent points */
|
||||||
|
let length = Array.length(canvasShape.ws);
|
||||||
|
CanvasContext.setStrokeStyle(context, String, "#5680cc");
|
||||||
|
CanvasContext.lineWidth(context, 4.);
|
||||||
|
|
||||||
|
for (i in 1 to length - 1) {
|
||||||
|
let point0 = Convert.getPoint(canvasShape, i - 1);
|
||||||
|
let point1 = Convert.getPoint(canvasShape, i);
|
||||||
|
line(canvasElement, ~point0, ~point1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Draws the expected value line */
|
||||||
|
let continuousShape =
|
||||||
|
Convert.canvasShapeToContinuousShape(~canvasShape, ~canvasElement);
|
||||||
|
let mean = Distributions.Continuous.T.mean(continuousShape);
|
||||||
|
let variance = Distributions.Continuous.T.variance(continuousShape);
|
||||||
|
let meanLocation =
|
||||||
|
Convert.findClosestInOrderedArrayDangerously(mean, canvasShape.xValues);
|
||||||
|
let meanLocationCanvasX = canvasShape.ws[meanLocation];
|
||||||
|
let meanLocationCanvasY = canvasShape.hs[meanLocation];
|
||||||
|
CanvasContext.beginPath(context);
|
||||||
|
CanvasContext.setStrokeStyle(context, String, "#5680cc");
|
||||||
|
CanvasContext.setLineDash(context, [|5, 10|]);
|
||||||
|
|
||||||
|
line(
|
||||||
|
canvasElement,
|
||||||
|
~point0={w: meanLocationCanvasX, h: p00.h},
|
||||||
|
~point1={w: meanLocationCanvasX, h: meanLocationCanvasY},
|
||||||
|
);
|
||||||
|
CanvasContext.stroke(context);
|
||||||
|
CanvasContext.setLineDash(context, [||]);
|
||||||
|
|
||||||
|
/* draws lines parallel to x axis + factors to help w/ precise drawing. */
|
||||||
|
CanvasContext.beginPath(context);
|
||||||
|
CanvasContext.setStrokeStyle(context, String, "#CCC");
|
||||||
|
CanvasContext.lineWidth(context, 2.);
|
||||||
|
CanvasContext.font(context, "18px Roboto");
|
||||||
|
CanvasContext.textAlign(context, "center");
|
||||||
|
|
||||||
|
let numLines = 8;
|
||||||
|
let height =
|
||||||
|
float_of_int(rectangle.height)
|
||||||
|
*. CanvasContext.paddingRatioX
|
||||||
|
/. float_of_int(numLines);
|
||||||
|
|
||||||
|
for (i in 0 to numLines - 1) {
|
||||||
|
let pLeft = {...p00, h: p00.h -. height *. float_of_int(i)};
|
||||||
|
let pRight = {...p10, h: p10.h -. height *. float_of_int(i)};
|
||||||
|
line(canvasElement, ~point0=pLeft, ~point1=pRight);
|
||||||
|
CanvasContext.fillText(
|
||||||
|
string_of_int(i) ++ "x",
|
||||||
|
~x=pLeft.w -. float_of_int(rectangle.left) +. 15.0,
|
||||||
|
~y=pLeft.h -. float_of_int(rectangle.top) -. 5.0,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Draw a frame around the drawable area */
|
||||||
|
CanvasContext.lineWidth(context, 2.0);
|
||||||
|
CanvasContext.setStrokeStyle(context, String, "#000");
|
||||||
|
line(canvasElement, ~point0=p00, ~point1=p01);
|
||||||
|
line(canvasElement, ~point0=p01, ~point1=p11);
|
||||||
|
line(canvasElement, ~point0=p11, ~point1=p10);
|
||||||
|
line(canvasElement, ~point0=p10, ~point1=p00);
|
||||||
|
|
||||||
|
/* draw units along the x axis */
|
||||||
|
CanvasContext.font(context, "16px Roboto");
|
||||||
|
CanvasContext.lineWidth(context, 2.0);
|
||||||
|
let numUnits = 10;
|
||||||
|
let width = float_of_int(rectangle.width);
|
||||||
|
let height = float_of_int(rectangle.height);
|
||||||
|
let xMin = canvasShape.xValues[0];
|
||||||
|
let xMax = canvasShape.xValues[length - 1];
|
||||||
|
let xSpan = (xMax -. xMin) /. float_of_int(numUnits);
|
||||||
|
|
||||||
|
for (i in 0 to numUnits - 1) {
|
||||||
|
let x =
|
||||||
|
float_of_int(rectangle.left)
|
||||||
|
+. width
|
||||||
|
*. float_of_int(i)
|
||||||
|
/. float_of_int(numUnits);
|
||||||
|
let dashValue = xMin +. xSpan *. float_of_int(i);
|
||||||
|
CanvasContext.fillText(
|
||||||
|
Js.Float.toFixedWithPrecision(dashValue, ~digits=2),
|
||||||
|
~x,
|
||||||
|
~y=height,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
line(
|
||||||
|
canvasElement,
|
||||||
|
~point0={w: x +. CanvasContext.paddingFactorX(rectangle.width), h: p00.h},
|
||||||
|
~point1={
|
||||||
|
w: x +. CanvasContext.paddingFactorX(rectangle.width),
|
||||||
|
h: p00.h +. 10.0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let initialDistribution = (canvasElement: Dom.element, setState) => {
|
||||||
|
|
||||||
|
let mean = 10.0;
|
||||||
|
let stdev = 4.0;
|
||||||
|
let numSamples = 3000;
|
||||||
|
|
||||||
|
let normal: SymbolicDist.dist = `Normal({mean, stdev});
|
||||||
|
let normalShape = SymbolicDist.GenericSimple.toShape(normal, numSamples);
|
||||||
|
let xyShape: Types.xyShape =
|
||||||
|
switch (normalShape) {
|
||||||
|
| Mixed(_) => {xs: [||], ys: [||]}
|
||||||
|
| Discrete(_) => {xs: [||], ys: [||]}
|
||||||
|
| Continuous(m) => Distributions.Continuous.getShape(m)
|
||||||
|
};
|
||||||
|
|
||||||
|
/* // To use a lognormal instead:
|
||||||
|
let lognormal = SymbolicDist.Lognormal.fromMeanAndStdev(mean, stdev);
|
||||||
|
let lognormalShape =
|
||||||
|
SymbolicDist.GenericSimple.toShape(lognormal, numSamples);
|
||||||
|
let lognormalXYShape: Types.xyShape =
|
||||||
|
switch (lognormalShape) {
|
||||||
|
| Mixed(_) => {xs: [||], ys: [||]}
|
||||||
|
| Discrete(_) => {xs: [||], ys: [||]}
|
||||||
|
| Continuous(m) => Distributions.Continuous.getShape(m)
|
||||||
|
};
|
||||||
|
*/
|
||||||
|
|
||||||
|
let canvasShape = Convert.xyShapeToCanvasShape(~xyShape, ~canvasElement);
|
||||||
|
/* let continuousShapeBack =
|
||||||
|
Convert.canvasShapeToContinuousShape(~canvasShape, ~canvasElement);
|
||||||
|
*/
|
||||||
|
|
||||||
|
setState((state: Types.canvasState) => {
|
||||||
|
{...state, canvasShape: Some(canvasShape)}
|
||||||
|
});
|
||||||
|
|
||||||
|
canvasPlot(canvasElement, canvasShape);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module ForetoldAPI = {
|
||||||
|
let predict = (~measurableId, ~token, ~xs, ~ys, ~comment) => {
|
||||||
|
let payload = Js.Dict.empty();
|
||||||
|
let xsString = Js.Array.toString(xs);
|
||||||
|
let ysString = Js.Array.toString(ys);
|
||||||
|
|
||||||
|
let query = {j|mutation {
|
||||||
|
measurementCreate(input: {
|
||||||
|
value: {
|
||||||
|
floatCdf: {
|
||||||
|
xs: [$xsString]
|
||||||
|
ys: [$ysString]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
valueText: "Drawn by hand."
|
||||||
|
description: "$comment"
|
||||||
|
competitorType: COMPETITIVE
|
||||||
|
measurableId: "$measurableId"
|
||||||
|
}) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}|j};
|
||||||
|
Js.Dict.set(payload, "query", Js.Json.string(query));
|
||||||
|
|
||||||
|
Js.Promise.(
|
||||||
|
Fetch.fetchWithInit(
|
||||||
|
"https://api.foretold.io/graphql?token=" ++ token,
|
||||||
|
Fetch.RequestInit.make(
|
||||||
|
~method_=Post,
|
||||||
|
~body=
|
||||||
|
Fetch.BodyInit.make(
|
||||||
|
Js.Json.stringify(Js.Json.object_(payload)),
|
||||||
|
),
|
||||||
|
~headers=
|
||||||
|
Fetch.HeadersInit.make({
|
||||||
|
"Accept-Encoding": "gzip, deflate, br",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"DNT": "1",
|
||||||
|
"Origin": "https://api.foretold.io",
|
||||||
|
}),
|
||||||
|
(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|> then_(Fetch.Response.json)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module State = {
|
||||||
|
type t = Types.canvasState;
|
||||||
|
|
||||||
|
let initialState: t = {
|
||||||
|
isMouseDown: false,
|
||||||
|
lastMousePosition: None,
|
||||||
|
canvasShape: None,
|
||||||
|
readyToRender: false,
|
||||||
|
hasJustBeenSent: false,
|
||||||
|
formElements: {
|
||||||
|
measurableId: "",
|
||||||
|
token: "",
|
||||||
|
comment: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let updateMousePosition = (~point: Types.canvasPoint, ~setState) =>{
|
||||||
|
setState((state: t) => ({...state, lastMousePosition: Some(point)}));
|
||||||
|
};
|
||||||
|
|
||||||
|
let onMouseMovement =
|
||||||
|
(
|
||||||
|
~event: ReactEvent.Mouse.t,
|
||||||
|
~potentialCanvas: option(Dom.element),
|
||||||
|
~state: t,
|
||||||
|
~setState,
|
||||||
|
) => {
|
||||||
|
|
||||||
|
/* Helper functions and objects*/
|
||||||
|
let x = ReactEvent.Mouse.clientX(event);
|
||||||
|
let y = ReactEvent.Mouse.clientY(event);
|
||||||
|
|
||||||
|
let point1: Types.canvasPoint = {
|
||||||
|
w: float_of_int(x),
|
||||||
|
h: float_of_int(y),
|
||||||
|
};
|
||||||
|
|
||||||
|
let pointIsInBetween =
|
||||||
|
(a: Types.canvasPoint, b: Types.canvasPoint, c: Types.canvasPoint)
|
||||||
|
: bool => {
|
||||||
|
let x0 = a.w;
|
||||||
|
let x1 = b.w;
|
||||||
|
let x2 = c.w;
|
||||||
|
x0 < x2 && x2 < x1 || x1 < x2 && x2 < x0;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/* If all conditions are met, update the distribution */
|
||||||
|
let updateDistWithMouseMovement =
|
||||||
|
(
|
||||||
|
~point0: Types.canvasPoint,
|
||||||
|
~point1: Types.canvasPoint,
|
||||||
|
~canvasShape: Types.canvasShape,
|
||||||
|
) => {
|
||||||
|
/*
|
||||||
|
The mouse moves across the screen, and we get a series of (x,y) positions.
|
||||||
|
We know where the mouse last was
|
||||||
|
we update everything between the last (x,y) position and the new (x,y), using linear interpolation
|
||||||
|
Note that we only want to update & iterate over the parts of the canvas which are changed by the mouse movement
|
||||||
|
(otherwise, things might be too slow)
|
||||||
|
*/
|
||||||
|
|
||||||
|
let slope = (point1.h -. point0.h) /. (point1.w -. point0.w);
|
||||||
|
let pos0 =
|
||||||
|
Convert.findClosestInOrderedArrayDangerously(
|
||||||
|
point0.w,
|
||||||
|
canvasShape.ws,
|
||||||
|
);
|
||||||
|
let pos1 =
|
||||||
|
Convert.findClosestInOrderedArrayDangerously(
|
||||||
|
point1.w,
|
||||||
|
canvasShape.ws,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mouse is moving to the right
|
||||||
|
for (i in pos0 to pos1) {
|
||||||
|
let pointN = Convert.getPoint(canvasShape, i);
|
||||||
|
switch (pointIsInBetween(point0, point1, pointN)) {
|
||||||
|
| false => ()
|
||||||
|
| true =>
|
||||||
|
canvasShape.hs[i] = point0.h +. slope *. (pointN.w -. point0.w)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mouse is moving to the left
|
||||||
|
for (i in pos0 downto pos1) {
|
||||||
|
let pointN = Convert.getPoint(canvasShape, i);
|
||||||
|
switch (pointIsInBetween(point0, point1, pointN)) {
|
||||||
|
| false => ()
|
||||||
|
| true =>
|
||||||
|
canvasShape.hs[i] = point0.h +. slope *. (pointN.w -. point0.w)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
canvasShape;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Check that the mouse movement was within the paddding box. */
|
||||||
|
let validateYCoordinates =
|
||||||
|
(~point: Types.canvasPoint, ~rectangle: Types.rectangle) => {
|
||||||
|
switch (
|
||||||
|
/*
|
||||||
|
- If we also validate the xs, this produces a jaded user experience around the edges.
|
||||||
|
- Instead, we will also update the first and last points in the updateDistWithMouseMovement, with the findClosestInOrderedArrayDangerously function, even when the x is outside the padding zone
|
||||||
|
- When we send the distribution to foretold, we'll get rid of the first and last points.
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
point.w >= float_of_int(rectangle.left)
|
||||||
|
+. CanvasContext.paddingFactorX(rectangle.width),
|
||||||
|
point.w <= float_of_int(rectangle.right)
|
||||||
|
-. CanvasContext.paddingFactorX(rectangle.width),
|
||||||
|
*/
|
||||||
|
point.h >= float_of_int(rectangle.top)
|
||||||
|
+. CanvasContext.paddingFactorY(rectangle.height),
|
||||||
|
point.h <= float_of_int(rectangle.bottom)
|
||||||
|
-. CanvasContext.paddingFactorY(rectangle.height),
|
||||||
|
) {
|
||||||
|
| (true, true) => true
|
||||||
|
| _ => false
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let decideWithCanvas = (~canvasElement, ~canvasShape, ~point0) => {
|
||||||
|
let rectangle = CanvasContext.getBoundingClientRect(canvasElement);
|
||||||
|
switch (
|
||||||
|
validateYCoordinates(~point=point0, ~rectangle),
|
||||||
|
validateYCoordinates(~point=point1, ~rectangle),
|
||||||
|
) {
|
||||||
|
| (true, true) =>
|
||||||
|
let newCanvasShape = updateDistWithMouseMovement(~point0, ~point1, ~canvasShape);
|
||||||
|
state.readyToRender ? Draw.canvasPlot(canvasElement, newCanvasShape) : ();
|
||||||
|
setState((state: t) => {
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
lastMousePosition: Some(point1),
|
||||||
|
canvasShape: Some(newCanvasShape),
|
||||||
|
readyToRender: false,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
| (false, true) => updateMousePosition(~point=point1, ~setState)
|
||||||
|
| (_, false) => ()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (
|
||||||
|
potentialCanvas,
|
||||||
|
state.canvasShape,
|
||||||
|
state.isMouseDown,
|
||||||
|
state.lastMousePosition,
|
||||||
|
) {
|
||||||
|
| (Some(canvasElement), Some(canvasShape), true, Some(point0)) =>
|
||||||
|
decideWithCanvas(~canvasElement, ~canvasShape, ~point0);
|
||||||
|
| (Some(canvasElement), _, true, None) =>
|
||||||
|
let rectangle = CanvasContext.getBoundingClientRect(canvasElement);
|
||||||
|
validateYCoordinates(~point=point1, ~rectangle) ? updateMousePosition(~point=point1, ~setState) : ();
|
||||||
|
| _ => ()
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let onMouseClick = (~setState, ~state) => {
|
||||||
|
setState((state: t) => {
|
||||||
|
{...state, isMouseDown: !state.isMouseDown, lastMousePosition: None}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let onSubmitForm =
|
||||||
|
(
|
||||||
|
~state: Types.canvasState,
|
||||||
|
~potentialCanvasElement: option(Dom.element),
|
||||||
|
~setState,
|
||||||
|
) => {
|
||||||
|
let potentialCanvasShape = state.canvasShape;
|
||||||
|
|
||||||
|
switch (potentialCanvasShape, potentialCanvasElement) {
|
||||||
|
| (None, _) => ()
|
||||||
|
| (_, None) => ()
|
||||||
|
| (Some(canvasShape), Some(canvasElement)) =>
|
||||||
|
|
||||||
|
let pdf =
|
||||||
|
Convert.canvasShapeToContinuousShape(~canvasShape, ~canvasElement);
|
||||||
|
|
||||||
|
/* create a cdf from a pdf */
|
||||||
|
let _pdf =
|
||||||
|
Distributions.Continuous.T.scaleToIntegralSum(
|
||||||
|
~cache=None,
|
||||||
|
~intendedSum=1.0,
|
||||||
|
pdf,
|
||||||
|
);
|
||||||
|
let cdf = Distributions.Continuous.T.integral(~cache=None, _pdf);
|
||||||
|
let xs = [||];
|
||||||
|
let ys = [||];
|
||||||
|
for (i in 1 to 999) {
|
||||||
|
/*
|
||||||
|
- see comment in validateYCoordinates as to why this starts at 1.
|
||||||
|
- foretold accepts distributions with up to 1000 points.
|
||||||
|
*/
|
||||||
|
let j = i * 3;
|
||||||
|
Js.Array.push(cdf.xyShape.xs[j], xs);
|
||||||
|
Js.Array.push(cdf.xyShape.ys[j], ys);
|
||||||
|
};
|
||||||
|
ForetoldAPI.predict(
|
||||||
|
~measurableId=state.formElements.measurableId,
|
||||||
|
~token=state.formElements.token,
|
||||||
|
~comment=state.formElements.comment,
|
||||||
|
~xs,
|
||||||
|
~ys,
|
||||||
|
);
|
||||||
|
setState((state: t) => {...state, hasJustBeenSent: true});
|
||||||
|
Js.Global.setTimeout(
|
||||||
|
() => {
|
||||||
|
setState((state: t) => {...state, hasJustBeenSent: false});
|
||||||
|
},
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
();
|
||||||
|
};
|
||||||
|
();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module Styles = {
|
||||||
|
open Css;
|
||||||
|
let dist = style([padding(em(1.))]);
|
||||||
|
let spacer = style([marginTop(em(1.))]);
|
||||||
|
};
|
||||||
|
|
||||||
|
[@react.component]
|
||||||
|
let make = () => {
|
||||||
|
|
||||||
|
let canvasRef: React.Ref.t(option(Dom.element)) = React.useRef(None); // should morally live inside the state, but this is tricky.
|
||||||
|
let (state, setState) = React.useState(() => State.initialState);
|
||||||
|
|
||||||
|
/* Draw the initial distribution */
|
||||||
|
React.useEffect0(() => {
|
||||||
|
let potentialCanvas = React.Ref.current(canvasRef);
|
||||||
|
(
|
||||||
|
switch (potentialCanvas) {
|
||||||
|
| Some(canvasElement) =>
|
||||||
|
Draw.initialDistribution(canvasElement, setState)
|
||||||
|
| None => ()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|> ignore;
|
||||||
|
None;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Render the current distribution every 40ms, while the mouse is moving and changing it */
|
||||||
|
React.useEffect0(() => {
|
||||||
|
let runningInterval =
|
||||||
|
Js.Global.setInterval(
|
||||||
|
() => {
|
||||||
|
setState((state: Types.canvasState) => {
|
||||||
|
{...state, readyToRender: true}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
40,
|
||||||
|
);
|
||||||
|
Some(() => Js.Global.clearInterval(runningInterval));
|
||||||
|
});
|
||||||
|
|
||||||
|
<Antd.Card title={"Distribution Drawer" |> R.ste}>
|
||||||
|
<div className=Styles.spacer />
|
||||||
|
<p>{"Click to begin drawing, click to stop drawing" |> R.ste}</p>
|
||||||
|
<canvas
|
||||||
|
width="1000"
|
||||||
|
height="700"
|
||||||
|
ref={ReactDOMRe.Ref.callbackDomRef(elem =>
|
||||||
|
React.Ref.setCurrent(canvasRef, Js.Nullable.toOption(elem))
|
||||||
|
)}
|
||||||
|
onMouseMove={event =>
|
||||||
|
State.onMouseMovement(
|
||||||
|
~event,
|
||||||
|
~potentialCanvas=React.Ref.current(canvasRef),
|
||||||
|
~state,
|
||||||
|
~setState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={_e => State.onMouseClick(~setState, ~state)}
|
||||||
|
/>
|
||||||
|
<div className=Styles.spacer />
|
||||||
|
<div className=Styles.spacer />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<Antd.Card title={"Send to foretold" |> R.ste}>
|
||||||
|
<form
|
||||||
|
id="send-to-foretold"
|
||||||
|
onSubmit={(e: ReactEvent.Form.t): unit => {
|
||||||
|
ReactEvent.Form.preventDefault(e);
|
||||||
|
/* code to run on submit */
|
||||||
|
State.onSubmitForm(
|
||||||
|
~state,
|
||||||
|
~potentialCanvasElement=React.Ref.current(canvasRef),
|
||||||
|
~setState,
|
||||||
|
);
|
||||||
|
();
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<label> {"MeasurableId: " |> R.ste} </label>
|
||||||
|
<input
|
||||||
|
type_="text"
|
||||||
|
id="measurableId"
|
||||||
|
name="measurableId"
|
||||||
|
value={state.formElements.measurableId}
|
||||||
|
placeholder="The last bit in the url, after the m"
|
||||||
|
required=true
|
||||||
|
onChange={event => {
|
||||||
|
let value = ReactEvent.Form.target(event)##value;
|
||||||
|
setState((state: Types.canvasState) => {
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
formElements: {
|
||||||
|
...state.formElements,
|
||||||
|
measurableId: value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div>
|
||||||
|
<label> {"Foretold bot token: " |> R.ste} </label>
|
||||||
|
<input
|
||||||
|
type_="text"
|
||||||
|
id="foretoldToken"
|
||||||
|
name="foretoldToken"
|
||||||
|
value={state.formElements.token}
|
||||||
|
placeholder="Profile -> Bots -> (New Bot) -> Token"
|
||||||
|
required=true
|
||||||
|
onChange={event => {
|
||||||
|
let value = ReactEvent.Form.target(event)##value;
|
||||||
|
setState((state: Types.canvasState) => {
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
formElements: {
|
||||||
|
...state.formElements,
|
||||||
|
token: value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<textarea
|
||||||
|
id="comment"
|
||||||
|
name="comment"
|
||||||
|
rows=20
|
||||||
|
cols=70
|
||||||
|
placeholder="Explain a little bit what this distribution is about"
|
||||||
|
onChange={event => {
|
||||||
|
let value = ReactEvent.Form.target(event)##value;
|
||||||
|
setState((state: Types.canvasState) => {
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
formElements: {
|
||||||
|
...state.formElements,
|
||||||
|
comment: value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
<button type_="submit" id="sendToForetoldButton">
|
||||||
|
{"Send to foretold" |> R.ste}
|
||||||
|
</button>
|
||||||
|
<br />
|
||||||
|
<p hidden={!state.hasJustBeenSent}> {"Sent!" |> R.ste} </p>
|
||||||
|
</form>
|
||||||
|
</Antd.Card>
|
||||||
|
</Antd.Card>;
|
||||||
|
};
|
12
yarn.lock
12
yarn.lock
|
@ -2476,6 +2476,11 @@ bs-css@11.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
emotion "^10.0.7"
|
emotion "^10.0.7"
|
||||||
|
|
||||||
|
bs-fetch@^0.5.2:
|
||||||
|
version "0.5.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/bs-fetch/-/bs-fetch-0.5.2.tgz#a9f4582ddb1414d3467b900305c738813481988c"
|
||||||
|
integrity sha512-CYweTJcgLeLOqzR3vNsB+NmyEFhWThnPNdZmmF28nkFmo0CQEf+20eSHZJDO6EuAhhoRqJ0qp2VgxtH6zBB5xA==
|
||||||
|
|
||||||
bs-moment@0.4.4:
|
bs-moment@0.4.4:
|
||||||
version "0.4.4"
|
version "0.4.4"
|
||||||
resolved "https://registry.yarnpkg.com/bs-moment/-/bs-moment-0.4.4.tgz#3d59767e8cd0107393c4f371e15b95bf862ceaac"
|
resolved "https://registry.yarnpkg.com/bs-moment/-/bs-moment-0.4.4.tgz#3d59767e8cd0107393c4f371e15b95bf862ceaac"
|
||||||
|
@ -2486,7 +2491,7 @@ bs-moment@0.4.5, bs-moment@^0.4.4:
|
||||||
resolved "https://registry.yarnpkg.com/bs-moment/-/bs-moment-0.4.5.tgz#3f84fed55c2a70d25b0b6025e4e8d821fcdd4dc8"
|
resolved "https://registry.yarnpkg.com/bs-moment/-/bs-moment-0.4.5.tgz#3f84fed55c2a70d25b0b6025e4e8d821fcdd4dc8"
|
||||||
integrity sha512-anPYkFSof+X8EeomnP0fbQBvWFJeganwPqqARVB+fcdKYX2Uog/n3CCiFGEA+66yHbwnWZD5YFhtHCuyLMcQfQ==
|
integrity sha512-anPYkFSof+X8EeomnP0fbQBvWFJeganwPqqARVB+fcdKYX2Uog/n3CCiFGEA+66yHbwnWZD5YFhtHCuyLMcQfQ==
|
||||||
|
|
||||||
bs-platform@7.2.2:
|
bs-platform@7.2.2, bs-platform@^7.2.2:
|
||||||
version "7.2.2"
|
version "7.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/bs-platform/-/bs-platform-7.2.2.tgz#76fdc63e4889458ae3d257a0132107a792f2309c"
|
resolved "https://registry.yarnpkg.com/bs-platform/-/bs-platform-7.2.2.tgz#76fdc63e4889458ae3d257a0132107a792f2309c"
|
||||||
integrity sha512-PWcFfN+jCTtT/rMaHDhKh+W9RUTpaRunmSF9vbLYcrJbpgCNW6aFKAY33u0P3mLxwuhshN3b4FxqGUBPj6exZQ==
|
integrity sha512-PWcFfN+jCTtT/rMaHDhKh+W9RUTpaRunmSF9vbLYcrJbpgCNW6aFKAY33u0P3mLxwuhshN3b4FxqGUBPj6exZQ==
|
||||||
|
@ -2500,6 +2505,11 @@ bs-reform@9.7.1:
|
||||||
reason-react-update "^1.0.0"
|
reason-react-update "^1.0.0"
|
||||||
reschema "^1.3.0"
|
reschema "^1.3.0"
|
||||||
|
|
||||||
|
bs-webapi@^0.15.9:
|
||||||
|
version "0.15.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/bs-webapi/-/bs-webapi-0.15.9.tgz#d1dcfbd40499a3b0914daacc4ceef47a0f3c906f"
|
||||||
|
integrity sha512-bmzO6na2HmK01a34qB7afMwfVSrPxkP3EGzUslWObuxbHIlLC0PAMDu4lA8ZL5NasBUctlwnA1QZxox+1aNWWw==
|
||||||
|
|
||||||
bsb-js@1.1.7:
|
bsb-js@1.1.7:
|
||||||
version "1.1.7"
|
version "1.1.7"
|
||||||
resolved "https://registry.yarnpkg.com/bsb-js/-/bsb-js-1.1.7.tgz#12cc91e974f5896b3a2aa8358419d24e56f552c3"
|
resolved "https://registry.yarnpkg.com/bsb-js/-/bsb-js-1.1.7.tgz#12cc91e974f5896b3a2aa8358419d24e56f552c3"
|
||||||
|
|
Loading…
Reference in New Issue
Block a user