feat: cleanup

This commit is contained in:
NunoSempere 2022-07-19 19:00:29 -04:00
parent 4fddea47bf
commit ec9dd815cc
19 changed files with 52 additions and 596 deletions

View File

@ -1,28 +1,14 @@
## About
This repository creates a react webpage that allows to extract a utility function from possibly inconsistent binary comparisons.
It presents the users with a series of elements to compare, using merge-sort in the background to cleverly minimize the number of choices needed.
This repository displays hierarchical estimates. It presents the user with a tree with nodes which can be clicked on and inspected, which visualizes the underlying squiggle calculation.
<p align="center">
<img width="50%" height="50%" src="./public/example-prompt.png">
<img width="50%" height="50%" src="./public/example-hierarchical.png">
</p>
Then, it cleverly aggregates them, on the one hand by producing a graphical representation:
So far, this mostly has visualization capabilities, meaning that edits are impermanent.
<p align="center">
<img width="50%" height="50%" src="./public/example-graph.png">
</p>
and on the other hand doing some fast and clever mean aggregation [^1]:
<p align="center">
<img width="50%" height="50%" src="./public/example-table.png">
</p>
Initially, users could only input numbers, e.g., "A is `3` times better than B". But now, users can also input distributions, using the [squiggle](https://www.squiggle-language.com/) syntax, e.g., "A is `1 to 10` times better than B", or "A is `mm(normal(1, 10), uniform(0,100))` better than B".
**If you want to use the utility function extractor for a project, we are happy to add a page for your project, like `utility-function-extractor.quantifieduncertainty.org/your-project`**.
**If you want to use the utility function extractor for a project, we are happy to add a page for your project, like `hierarchical-visualization.quantifieduncertainty.org/your-project`**.
## Built with
@ -30,56 +16,10 @@ Initially, users could only input numbers, e.g., "A is `3` times better than B".
- [Netlify](https://github.com/netlify/netlify-plugin-nextjs/#readme)
- [React](https://reactjs.org/)
- [Squiggle](https://www.squiggle-language.com/)
- [Utility tools](https://github.com/quantified-uncertainty/utility-function-extractor/tree/master/packages/utility-tools)
## Usage
Navigate to [utility-function-extractor.quantifieduncertainty.org/](https://utility-function-extractor.quantifieduncertainty.org/), and start comparing objects.
You can change the list of objects to be compared by clicking on "advanced options".
After comparing objects for a while, you will get a table and a graph with results. You can also use the [utility tools](https://github.com/quantified-uncertainty/utility-function-extractor/tree/master/packages/utility-tools) package to process these results, for which you will need the json of comparisons, which can be found in "Advanced options" -> "Load comparisons"
## Notes
The core structure is json array of objects. Only the "name" attribute is required. If there is a "url", it is displayed nicely.
```
[
{
"name": "Peter Parker",
"someOptionalKey": "...",
"anotherMoreOptionalKey": "...",
},
{
"name": "Spiderman",
"someOptionalKey": "...",
"anotherMoreOptionalKey": "..."
}
]
```
The core structure for links is as follows:
```
[
{
"source": "Peter Parker",
"target": "Spiderman",
"squiggleString": "1 to 100",
"distance": 26.639800977355474
},
{
"source": "Spiderman",
"target": "Jonah Jameson",
"squiggleString": "20 to 2000",
"distance": 6.76997149080232
},
]
```
A previous version of this webpage had a more complicated structure, but it has since been simplified.
Navigate to [hierarchical-visualization.quantifieduncertainty.org/](https://utility-function-extractor.quantifieduncertainty.org/) (to do: point to subdomain), and start visualizing.
## Contributions and help
@ -91,24 +31,4 @@ Distributed under the MIT License. See LICENSE.txt for more information.
## To do
- [x] Extract merge, findPath and aggregatePath functionality into different repos
- [x] Send to mongo upon completion
- [x] Push to github
- [x] Push to netlify
- [x] Don't allow further comparisons after completion
- [x] Paths table
- [x] Add paths table
- [x] warn that the paths table is approximate.
- I really don't feel like re-adding this after having worked out the distribution rather than the mean aggregation
- On the other hand, I think it does make it more user to other users.
- [x] Change README.
- [ ] Add functionality like names, etc.
- I also don't feel like doing this
- [ ] Look back at Amazon thing which has been running
- [ ] Simplify Graph and DynamicSquiggleChart components
- [ ] Add squiggle component to initial comparison?
- [ ] Understand why the rewrite doesn't
- Maybe: When two elements are judged to be roughly equal
- Maybe: Slightly different merge-sort algorithm.
[^1]: The program takes each element as a reference point in turn, and computing the possible distances from that reference point to all other points, and taking the geometric mean of these distances. This produces a number representing the value of each element, such that the ratios between elements represent the user's preferences: a utility function. However, this isn't perfect; the principled approach woud be to aggregate the distributions rather than their means. But this principled approach is much more slowly. For the principled approach, see the `utility-tools` repository.
- [ ] Allow for edits.

View File

@ -1,85 +0,0 @@
import React, { useState } from "react";
import { ShowComparisons } from "./showComparisons.js";
import { ComparisonsChanger } from "./comparisonsChanger.js";
import { DataSetChanger } from "./datasetChanger.js";
import { setRevalidateHeaders } from "next/dist/server/send-payload/revalidate-headers.js";
const effectButtonStyle =
"bg-transparent m-2 hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent rounded mt-5";
export function AdvancedOptions({
links,
setLinks,
listOfElements,
moveToNextStep,
onChangeOfDataset,
}) {
const [showAdvancedOptions, changeShowAdvanceOptions] = useState(false);
const [showComparisons, setShowComparisons] = useState(false);
const toggleShowComparisons = () => setShowComparisons(!showComparisons);
const [showLoadComparisons, setShowLoadComparisons] = useState(false);
const toggleShowLoadComparisons = () =>
setShowLoadComparisons(!showLoadComparisons);
const [showChangeDataset, setShowChangeDataset] = useState(false);
const toggleShowChangeDataset = () =>
setShowChangeDataset(!showChangeDataset);
const buttonNames = [
// "Show Comparisons",
"Load comparisons",
"Use your own data",
];
const buttonToggles = [
// toggleShowComparisons,
toggleShowLoadComparisons,
toggleShowChangeDataset,
];
return (
<div className="">
{/* Show advanced options*/}
<button
key={"advancedOptionsButton-top"}
className="text-gray-500 text-sm "
onClick={() => changeShowAdvanceOptions(!showAdvancedOptions)}
>
Advanced options
</button>
{/* Toggle buttons */}
<div className={showAdvancedOptions ? "" : "hidden"}>
{buttonNames.map((buttonName, i) => {
return (
<button
className={effectButtonStyle}
onClick={() => buttonToggles[i]()}
key={`advancedOptionsButton-${i}`}
>
{buttonName}
</button>
);
})}
{/* Element: Show comparisons */}
{/* <ShowComparisons links={links} show={showComparisons} /> */}
{/* Element: Change comparisons */}
<ComparisonsChanger
setLinks={setLinks}
listOfElements={listOfElements}
show={showLoadComparisons}
moveToNextStep={moveToNextStep}
links={links}
/>
{/* Element: Dataset changer */}
<DataSetChanger
onChangeOfDataset={onChangeOfDataset}
show={showChangeDataset}
listOfElements={listOfElements}
/>
</div>
</div>
);
}

View File

@ -1,105 +0,0 @@
import React, { useState, useEffect } from "react";
import { Separator } from "../separator.js";
const checkLinksAreOk = (links, listOfElements) => {
let linkSourceNames = links.map((link) => link.source.name);
let linkTargetNames = links.map((link) => link.target.name);
let allLinkNames = [...linkSourceNames, ...linkTargetNames];
let uniqueNames = [...new Set(allLinkNames)];
let listOfElementNames = listOfElements.map((element) => element.name);
let anyInvalidNames = uniqueNames.indexOf(
(name) => !listOfElementNames.includes(name)
);
let anyElementsWithoutDistances = links.indexOf(
(link) => !link.distance && link.distance != 0
);
if (anyInvalidNames == -1 && anyElementsWithoutDistances == -1) {
return true;
} else {
return false;
}
};
export function ComparisonsChanger({
setLinks,
listOfElements,
show,
moveToNextStep,
links,
}) {
let [value, setValue] = useState(JSON.stringify(links, null, 4));
const [displayingDoneMessage, setDisplayingDoneMessage] = useState(false);
const [displayingDoneMessageTimer, setDisplayingDoneMessageTimer] =
useState(null);
let handleTextChange = (event) => {
setValue(event.target.value);
};
let handleSubmitInner = (event) => {
clearTimeout(displayingDoneMessageTimer);
event.preventDefault();
try {
let newData = JSON.parse(value);
if (checkLinksAreOk(newData, listOfElements)) {
setLinks(newData);
moveToNextStep({
listOfElements,
whileChangingStuff: true,
newLinksFromChangingStuff: newData,
});
setDisplayingDoneMessage(true);
let timer = setTimeout(() => setDisplayingDoneMessage(false), 3000);
setDisplayingDoneMessageTimer(timer);
} else {
throw Error("Links are not ok");
}
} catch (error) {
setDisplayingDoneMessage(false);
alert(error);
}
};
useEffect(() => {
setValue(JSON.stringify(links, null, 4));
}, [links]);
return (
<form
onSubmit={handleSubmitInner}
className={`inline ${show ? "" : "hidden"}`}
>
<Separator />
<h3 className="text-lg mt-8">Load comparisons</h3>
<p>These can be edited, which will override your current comparisons.</p>
<br />
<textarea
value={value}
onChange={handleTextChange}
rows={4 + JSON.stringify(links, null, 4).split("\n").length}
cols={70}
className="text-left text-gray-600 bg-white rounded text-normal p-10 border-0 shadow outline-none focus:outline-none focus:ring "
/>
<br />
<button
className="bg-transparent hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent rounded mt-5 p-10"
onClick={handleSubmitInner}
>
Change comparisons
</button>
&nbsp;
<button
className={
displayingDoneMessage
? "bg-transparent text-blue-700 font-semibold py-2 px-4 border border-blue-500 rounded mt-5 p-10"
: "hidden"
}
>
Done!
</button>
</form>
);
}

View File

@ -1,86 +0,0 @@
import React, { useState } from "react";
import { Separator } from "../separator.js";
export function DataSetChanger({ onChangeOfDataset, show, listOfElements }) {
/*let [value, setValue] = useState(`[
{
"name": "Some element. The name field is necessary",
"url": "http://www.example.com",
"somethirdfield": "a"
},
{
"name": "Another element",
"url": "http://www.example1.com",
"somethirdfield": "b"
},
{
"name": "A third element",
"url": "http://www.example2.com",
"isReferenceValue": true,
"somethirdfield": "c"
}
]`);*/
let [value, setValue] = useState(JSON.stringify(listOfElements, null, 4));
const [displayingDoneMessage, setDisplayingDoneMessage] = useState(false);
const [displayingDoneMessageTimer, setDisplayingDoneMessageTimer] =
useState(null);
let handleChange = (event) => {
setValue(event.target.value);
};
let handleSubmitInner = (event) => {
clearTimeout(displayingDoneMessageTimer);
event.preventDefault();
console.log("value@handleSubmitInner@DataSetChanger");
console.log(value);
try {
let newData = JSON.parse(value);
if (!newData.length || newData.length < 2) {
throw Error("Not enough objects");
}
onChangeOfDataset(newData);
setDisplayingDoneMessage(true);
let timer = setTimeout(() => setDisplayingDoneMessage(false), 3000);
setDisplayingDoneMessageTimer(timer);
} catch (error) {
setDisplayingDoneMessage(false);
alert(error);
}
};
return (
<div className={`${show ? "" : "hidden"} `}>
<Separator />
<form onSubmit={handleSubmitInner} className="inline mt-0">
<h3 className="text-lg mt-8 mb-4">Change dataset</h3>
<textarea
value={value}
onChange={handleChange}
rows={
1.2 * JSON.stringify(listOfElements, null, 4).split("\n").length
}
cols={70}
className="text-left text-gray-600 bg-white rounded text-normal p-10 border-0 shadow outline-none focus:outline-none focus:ring "
/>
<br />
<button
className="bg-transparent hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent rounded mt-5 p-10"
onClick={handleSubmitInner}
>
Change dataset
</button>
&nbsp;
<button
className={
displayingDoneMessage
? "bg-transparent text-blue-700 font-semibold py-2 px-4 border border-blue-500 rounded mt-5 p-10"
: "hidden"
}
>
Done!
</button>
</form>
</div>
);
}

View File

@ -1,22 +0,0 @@
import React, { state } from "react";
import { CopyBlock, googlecode } from "react-code-blocks";
import { Separator } from "../separator.js";
export function ShowComparisons({ links, show }) {
return (
<div className={`text-left ${show ? "" : "hidden"}`}>
<Separator />
<h3 className="text-lg mt-8">Comparisons</h3>
<CopyBlock
text={JSON.stringify(links, null, 4)}
language={"js"}
showLineNumbers={false}
startingLineNumber={0}
wrapLines={true}
textColor={"black"}
theme={googlecode}
codeBlock={true}
/>
</div>
);
}

View File

@ -1,4 +1,4 @@
import React from "react";
import React, { useState, useEffect } from "react";
// import { SquiggleChart } from "@quri/squiggle-components";
import dynamic from "next/dynamic";
@ -10,20 +10,32 @@ const SquiggleChart = dynamic(
ssr: false,
}
);
/*
const SquiggleChart = dynamic(
() => import("@quri/squiggle-components").then((mod) => mod.SquiggleChart),
const SquiggleEditor = dynamic(
() => import("@quri/squiggle-components").then((mod) => mod.SquiggleEditor),
{
suspense: true,
loading: () => <p>Loading...</p>,
ssr: false,
}
);
*/
);
// ^ works, but updating the editor content from the outside would be tricky.
// and so instead we are hacking our own mini-editor.
const effectButtonStyle =
"bg-transparent m-2 hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent rounded mt-5";
const countNumberOfLines = string => {
return string.split("\n").length
}
export function DynamicSquiggleChart({ element, stopShowing }) {
const initialEditorState = !!element ? element.fn : ""
const [editorState, setEditorState] = useState(initialEditorState)
useEffect(() => {
if (!!element && element.fn != "") {
setEditorState(element.fn)
}
}, [element]);
if (element == null) {
return "";
} else {
@ -32,43 +44,28 @@ export function DynamicSquiggleChart({ element, stopShowing }) {
squiggleString: element.fn,
binding: element.binding || null
};
console.log(element.binding)
// alert(usefulElement.squiggleString)
return (
<div className="">
<h3 className="text-2xl font-bold mb-5">{usefulElement.name}</h3>
<textarea
value={usefulElement.squiggleString}
//onChange={handleChange}
disabled={true}
rows={5} // could compute from usefulElement.squiggleString
cols={30}
className="text-left text-gray-600 bg-white rounded text-normal p-8 m-8 border-0 shadow outline-none focus:outline-none focus:ring"
/>
<SquiggleChart
squiggleString={usefulElement.squiggleString}
width={500}
height={200}
bindings={usefulElement.binding}
showSummary={true}
showTypes={true}
/>
{/*
SquiggleChart props:
squiggleString?: string;
sampleCount?: number;
environment?: environment;
chartSettings?: FunctionChartSettings;
onChange?(expr: squiggleExpression): void;
width?: number;
height?: number;
bindings?: bindings;
jsImports?: jsImports;
showSummary?: boolean;
showTypes?: boolean;
showControls?: boolean;
*/}
<div className="bg-white p-8">
<h3 className="text-2xl font-bold mb-4">{usefulElement.name}</h3>
<textarea
value={editorState}
onChange={(event) => setEditorState(event.target.value)}
// disabled={true}
rows={countNumberOfLines(editorState) + 1}
cols={30}
className="text-left text-gray-600 bg-white rounded text-normal p-5 border-0 shadow outline-none focus:outline-none focus:ring"
/>
<SquiggleChart
squiggleString={editorState}
width={500}
height={200}
bindings={usefulElement.binding}
showSummary={true}
showTypes={true}
/>
</div>
<button className={effectButtonStyle} onClick={() => stopShowing()}>
Hide chart
</button>

View File

@ -1,6 +1,5 @@
import { run, runPartial, mergeBindings } from "@quri/squiggle-lang";
function miniReducer(obj, parentName) {
let nodes = []
let edges = []
@ -93,7 +92,6 @@ ${parentName}`;
let parentValue = run(parentResultSquiggleString, mergeBindings(bindings));
if (parentValue.tag == "Error") {
// console.log(bindings)
return parentValue
}
@ -106,7 +104,6 @@ ${parentName}`;
})
nodes.push(resultNode)
// console.log("resultNode", resultNode)
let result = {
...resultNode,
value: parentValue,
@ -139,20 +136,10 @@ mean(r)`,
if (reducerResult.tag == "Error") {
return reducerResult
} else {
// console.log("reducerResult", reducerResult)
let {nodes, edges} = reducerResult
let nodeElements = nodes.map(node => ({ data: { ...node, name: node.id} }))
let edgeElements = edges.map(edge => ({ data: { ...edge, name: edge.id } }))
let answer = { nodeElements, edgeElements }
return answer
}
// return { nodeElements: [], edgeElements: [] }
/*
*/
// return (resultUtility)
// Then the rest should be doable without all that much work.
// Some duplication of efforts, but I don't really care:
}

View File

@ -6,10 +6,9 @@ import { createGraph } from "./createGraph"
export function Graph({ }) {
const containerRef = useRef("hello-world");
const [cs, setCs] = useState(null); /// useState("invisible");
const [cs, setCs] = useState(null);
const [selectedElement, setSelectedElement] = useState(null);
const [selectedElementTimeout, setSelectedElementTimeout] = useState(null);
//let { nodeElements, edgeElements } = createGraph()
const graphInit = createGraph()
const [nodeElements, setNodeElements] = useState(graphInit.nodeElements)
const [edgeElements, setEdgeElements] = useState(graphInit.edgeElements)
@ -87,7 +86,7 @@ export function Graph({ }) {
name: "dagre", // circle, grid, dagre
minDist: 20,
rankDir: "BT",
//prelayout: false,
// prelayout: false,
// animate: false, // whether to transition the node positions
// animationDuration: 250, // duration of animation in ms if enabled
// the cytoscape documentation is pretty good here.
@ -104,7 +103,6 @@ export function Graph({ }) {
// necessary for themes like spread, which have
// a confusing animation at the beginning
};
useEffect(() => {
callEffect({
@ -128,18 +126,17 @@ export function Graph({ }) {
cs.nodes().on("click", (event) => {
clearTimeout(selectedElementTimeout);
let node = event.target;
console.log(JSON.stringify(node.json()));
// console.log(JSON.stringify(node.json()));
let newTimeout = setTimeout(() => {
let selectedElementIncomplete = (JSON.parse(JSON.stringify(node.json())).data)
let selectedElementName = selectedElementIncomplete.name
let selectedElementInFull = nodeElements.filter(node => node.data.name == selectedElementName)
console.log("selectedElementInFull", selectedElementInFull)
// console.log("selectedElementInFull", selectedElementInFull)
if(selectedElementInFull.length == 1){
let elementToBeSelected = selectedElementInFull[0]
console.log("elementToBeSelected", elementToBeSelected)
// console.log("elementToBeSelected", elementToBeSelected)
setSelectedElement(elementToBeSelected.data)
}
// setSelectedElement()
});
setSelectedElementTimeout(newTimeout)
});
@ -148,31 +145,6 @@ export function Graph({ }) {
}
}, [cs]);
/*
useEffect(() => {
if (cs != null) {
clearTimeout(selectedElementTimeout);
let newTimeout = setTimeout(() => {
cs.edges().on("mouseover", (event) => {
// on("click",
let edge = event.target;
// alert(JSON.stringify(edge.json()));
console.log(JSON.stringify(edge.json()));
setSelectedElement(JSON.parse(JSON.stringify(edge.json())).data);
});
cs.nodes().on("mouseover", (event) => {
// on("click",
let node = event.target;
// alert(JSON.stringify(edge.json()));
console.log(JSON.stringify(node.json()));
setSelectedElement(JSON.parse(JSON.stringify(node.json())).data);
});
}, 100);
setSelectedElementTimeout(newTimeout);
}
}, [cs]);
*/
return (
<div className="grid place-items-center">
<div
@ -185,8 +157,8 @@ export function Graph({ }) {
<div
ref={containerRef}
style={{
height: "900px", // isListOrdered ? "900px" : "500px",
width: "900px", // isListOrdered ? "900px" : "500px",
height: "900px",
width: "900px",
}}
className=""
/>

View File

@ -1,5 +1,5 @@
import React, { useState } from "react";
export function Title() {
return <h1 className="text-6xl font-bold ">Hierarchical Estimates Visualizer</h1>;
return <h1 className="text-6xl font-bold mb-8">Hierarchical Estimates Visualizer</h1>;
}

View File

@ -1,15 +0,0 @@
import axios from "axios";
const CONNECTION_IS_ACTIVE = true;
export async function pushToMongo(data) {
if (CONNECTION_IS_ACTIVE) {
let response = await axios.post(
"https://server.loki.red/utility-function-extractor",
{
data: data,
}
);
console.log(response);
}
}

View File

@ -1,57 +0,0 @@
import { run } from "@quri/squiggle-lang";
export async function resolveToNumIfPossible(comparisonString) {
if (!isNaN(comparisonString) && comparisonString != "") {
let response = {
asNum: true,
num: Number(comparisonString),
};
return response;
}
let squiggleMeanCommand = `mean(${comparisonString})`;
let squiggleResponse = await run(squiggleMeanCommand);
console.log(squiggleResponse);
if (squiggleResponse.tag == "Ok") {
let responseAsNumber = squiggleResponse.value.value;
let response = {
asNum: true,
num: Number(responseAsNumber),
};
return response;
} else {
let errorMsg = squiggleResponse.value;
let response = {
asNum: false,
errorMsg: errorMsg,
};
return response;
}
}
export async function getSquiggleSparkline(comparisonString) {
if (!isNaN(comparisonString) && comparisonString != "") {
let response = {
success: true,
sparkline: comparisonString,
};
return response;
}
let squiggleSparklineCommand = `sparkline(${comparisonString}, 20)`;
let squiggleResponse = await run(squiggleSparklineCommand);
console.log(squiggleResponse);
if (squiggleResponse.tag == "Ok") {
let responseAsNumber = squiggleResponse.value.value;
let response = {
success: true,
sparkline: responseAsNumber,
};
return response;
} else {
let errorMsg = squiggleResponse.value;
let response = {
success: false,
errorMsg: errorMsg,
};
return response;
}
}

View File

@ -1,11 +0,0 @@
export const cutOffLongNames = (string) => {
let maxLength = 40;
let result;
if (string.length < maxLength) {
result = string;
} else {
result = string.slice(0, maxLength - 4);
result = result + "...";
}
return result;
};

View File

@ -1,39 +0,0 @@
let topOutAt100AndValidate = (x) => {
if (x == x) {
return x > 99 ? 99 : x < 0 ? 2 : x;
} else {
return 10;
}
};
export const toLocale = (x) => Number(x).toLocaleString();
export const truncateValueForDisplay = (value) => {
let result;
if (value > 10) {
result = Number(Math.round(value).toPrecision(2));
} else if (value > 1) {
result = Math.round(value * 10) / 10;
} else if (value > 0) {
let candidateNumSignificantDigits =
-Math.floor(Math.log(value) / Math.log(10)) + 1;
let numSignificantDigits = topOutAt100AndValidate(
candidateNumSignificantDigits
);
result = value.toFixed(numSignificantDigits);
} else if (value == 0) {
return 0;
} else if (-1 < value) {
let candidateNumSignificantDigits =
-Math.floor(Math.log(Math.abs(value)) / Math.log(10)) + 1;
let numSignificantDigits = topOutAt100AndValidate(
candidateNumSignificantDigits
);
result = value.toFixed(numSignificantDigits);
} else if (value <= -1) {
result = "-" + toLocale(truncateValueForDisplay(-value));
} else {
result = toLocale(value); //return "~0"
}
return result;
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB