Skip to content

Commit

Permalink
feat: Added probability selector component [PT-185388713]
Browse files Browse the repository at this point in the history
  • Loading branch information
dougmartin committed Jul 17, 2023
1 parent 7f0ac19 commit 6202bda
Show file tree
Hide file tree
Showing 2 changed files with 238 additions and 26 deletions.
34 changes: 20 additions & 14 deletions src/components/drawing.scss
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
left: 0;
background: #000;
opacity: 0.25;
z-index: 100;
}
.nodeModal {
position: absolute;
Expand All @@ -52,24 +53,29 @@
display: flex;
justify-content: center;
align-items: center;
z-index: 101;

.nodeModalTitle {
background-color: #177991;
color: white;
padding: 5px 10px;
}
.nodeModalContent {
min-width: 200px;

form {
padding: 10px;
background-color: white;
display: flex;
flex-direction: column;
gap: 10px;
.nodeModalTitle {
background-color: #177991;
color: white;
padding: 5px 10px;
}

.nodeModalButtons {
form {
padding: 10px;
background-color: white;
display: flex;
gap: 5px;
justify-content: flex-end;
flex-direction: column;
gap: 10px;

.nodeModalButtons {
display: flex;
gap: 5px;
justify-content: flex-end;
}
}
}
}
Expand Down
230 changes: 218 additions & 12 deletions src/components/drawing.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { clsx } from "clsx";
import { nanoid } from "nanoid";

Expand All @@ -12,25 +12,230 @@ import DeleteIcon from "../assets/delete-icon.svg";

import "./drawing.scss";

const svgWidth = 200;
const svgHeight = 18;
const thumbRadius = 7;
const thumbDiameter = thumbRadius * 2;
const lineWidth = 2;
const lineStart = thumbRadius;
const lineEnd = svgWidth - thumbRadius;
const lineLength = lineEnd - lineStart;
const lineY = (svgHeight - lineWidth) / 2;

const distinctColors = [
"#e6194B", "#3cb44b", "#4363d8", "#f58231", "#42d4f4", "#ffe119",
"#f032e6", "#fabed4", "#469990", "#dcbeff", "#9A6324", "#fffac8",
"#800000", "#aaffc3", "#000075", "#a9a9a9", "#ffffff", "#000000"
];
const getDistinctColor = (i: number) => distinctColors[i % distinctColors.length];

interface ProbabilitySelectorOffset {
min: number
cur: number
max: number
}
interface ProbabilitySelectorThumbProps {
index: number
offset: ProbabilitySelectorOffset
visibleLineLength: number
onChange: (index: number, probability: number) => void;
}

export const ProbabilitySelectorThumb = (props: ProbabilitySelectorThumbProps) => {
const {index, offset, onChange} = props;

const handleMouseDown = useCallback((e: React.MouseEvent<SVGCircleElement>) => {
const startX = e.clientX;

const handleMouseMove = (e2: MouseEvent) => {
const delta = e2.clientX - startX;
const newOffset = Math.min(Math.max(offset.min, offset.cur + delta), offset.max);
const newProbability = (newOffset - offset.min) / (offset.max - offset.min);
onChange(index, newProbability);
};

const handleMouseUp = () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};

window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
}, [index, offset, onChange]);

return (
<circle
cx={offset.cur}
cy={lineY}
r={thumbRadius}
fill="#777"
style={{cursor: "pointer"}}
onMouseDown={handleMouseDown}
/>
);
};


interface ProbabilitySelectorProps {
edgeValues: number[]
edgeLabels: string[]
onChange: React.Dispatch<React.SetStateAction<number[]>>
}

const percentage = (n: number) => n * 100;
const probability = (n: number) => n / 100;
const reduceToSum = (a: number[]) => a.reduce((acc, cur) => acc + cur, 0);

export const ProbabilitySelector = ({edgeValues, edgeLabels, onChange}: ProbabilitySelectorProps) => {

const sum = useMemo(() => reduceToSum(edgeValues), [edgeValues]);

const exactPercentages = useMemo(() => {
const result = edgeValues.map(edgeValue => percentage(edgeValue / sum));

// make sure percentages add up to exactly 100%
const allButLastSum = reduceToSum(result.slice(0, -1));
result[result.length - 1] = 100 - allButLastSum;

return result;
}, [edgeValues, sum]);

const roundPercentages = useMemo(() => {
const result = exactPercentages.slice(0, -1).map(exactPercentage => Math.round(exactPercentage));
result.push(100 - reduceToSum(result));
return result;
}, [exactPercentages]);

const visibleLineLength = lineLength - ((exactPercentages.length - 1) * thumbDiameter);

const offsets = useMemo(() => {
let lastOffset = lineStart;
return exactPercentages.slice(0, -1).map((exactPercentage, i) => {
const offset: ProbabilitySelectorOffset = {
min: lineStart + (i * thumbRadius),
cur: lastOffset + (probability(exactPercentage) * lineLength),
max: lineEnd - (i * thumbRadius),
};
lastOffset = offset.cur;
return offset;
});
}, [exactPercentages]);

const segments = useMemo(() => {
if (offsets.length > 0) {
let start = offsets[0].min;
const result = offsets.map(offset => {
const segment = {
start,
end: offset.cur
};
start = offset.cur;
return segment;
});
result.push({
start: result[result.length - 1].end,
end: lineEnd
});
return result;
}
return [];
}, [offsets]);

const handleProbabilitySelectorThumbChange = useCallback((index: number, newProbability: number) => {
const newExactPercentages = [...exactPercentages];
const prevExactPercentage = exactPercentages[index - 1] || 0;
const exactPercentage = Math.min(100, Math.max(0, percentage(newProbability) - prevExactPercentage));
const delta = newExactPercentages[index] - exactPercentage;

newExactPercentages[index] = exactPercentage;
newExactPercentages[index+1] = Math.max(0, newExactPercentages[index+1] + delta);

const newEdgeValues = newExactPercentages.map(p => sum * probability(p));
onChange(newEdgeValues);
}, [exactPercentages, sum, onChange]);

if (edgeValues.length < 2) {
return null;
}

return (
<div>
<div style={{fontWeight: "bold"}}>Transition Probabilities</div>
<div style={{margin: "10px 0", width: svgWidth, height: svgHeight}}>
<svg height={"100%"} width={"100%"} viewBox={`0 0 ${svgWidth} ${svgHeight}`}>
{segments.map(({start, end}, i) => (
<line
key={i}
x1={start}
y1={lineY}
x2={end}
y2={lineY}
strokeWidth={lineWidth}
stroke={getDistinctColor(i)}
/>
))}
{offsets.map((offset, index) => (
<ProbabilitySelectorThumb
key={index}
index={index}
offset={offset}
visibleLineLength={visibleLineLength}
onChange={handleProbabilitySelectorThumbChange}
/>
))}
</svg>
</div>
<div style={{userSelect: "none"}}>
{roundPercentages.map((p, i) => (
<div key={i} style={{color: getDistinctColor(i)}}>To {edgeLabels[i]}: {p}%</div>
))}
</div>
</div>
);
};

interface NodeModalProps {
node?: Node,
onChange: (id: string, newNode: Node) => void,
graph: GraphData;
onChange: (id: string, newNode: Node, newEdge: Edge[]) => void,
onCancel: () => void
}

export const NodeModal = ({node, onChange, onCancel}: NodeModalProps) => {
export const NodeModal = ({node, graph, onChange, onCancel}: NodeModalProps) => {
const [label, setLabel] = useState(node?.label || "");
const [edgeLabels, setEdgeLabels] = useState<string[]>([]);
const [edgeValues, setEdgeValues] = useState<number[]>([]);

const fromEdges = useMemo(() => {
return node ? graph.edges.filter(e => e.from === node.id) : [];
}, [node, graph.edges]);

useEffect(() => {
const labels = graph.nodes.reduce<Record<string,string>>((acc, cur) => {
acc[cur.id] = cur.label;
return acc;
}, {});

setEdgeValues(fromEdges.map(e => e.value));
setEdgeLabels(fromEdges.map(e => labels[e.to]));
setLabel(node?.label || "");
}, [node]);

}, [node, fromEdges, graph.nodes, setEdgeValues, setEdgeLabels]);

const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
if (node) {
onChange(node.id, {...node, label: label.trim()});
let valueIndex = 0;
const newEdges = graph.edges.map(edge => {
if (edge.from === node.id) {
return {...edge, value: edgeValues[valueIndex++]};
} else {
return edge;
}
});
onChange(node.id, {...node, label: label.trim()}, newEdges);
}
}, [label, node, onChange]);
}, [label, node, edgeValues, graph.edges, onChange]);

const handleChangeLabel = (e: React.ChangeEvent<HTMLInputElement>) => {
setLabel(e.target.value);
Expand All @@ -40,14 +245,17 @@ export const NodeModal = ({node, onChange, onCancel}: NodeModalProps) => {
return null;
}

// check to see if there are edges from this node

return (
<>
<div className="nodeModalBackground" />
<div className="nodeModal">
<div>
<div className="nodeModalContent">
<div className="nodeModalTitle">Update State</div>
<form onSubmit={handleSubmit}>
<input type="text" value={label} onChange={handleChangeLabel} autoFocus={true} />
<ProbabilitySelector edgeValues={edgeValues} edgeLabels={edgeLabels} onChange={setEdgeValues} />
<div className="nodeModalButtons">
<button type="submit">Save</button>
<button onClick={onCancel}>Cancel</button>
Expand Down Expand Up @@ -291,16 +499,13 @@ export const Drawing = (props: Props) => {

const handleClearSelectedNode = useCallback(() => setSelectedNode(undefined), [setSelectedNode]);

const handleChangeNode = useCallback((id: string, newNode: Node) => {
const handleChangeNode = useCallback((id: string, newNode: Node, newEdges: Edge[]) => {
setGraph(prev => {
const nodeIndex = prev.nodes.findIndex(n => n.id === id);
if (nodeIndex !== -1) {
const nodes = [...prev.nodes];
nodes.splice(nodeIndex, 1, newNode);
return {
nodes,
edges: prev.edges
};
return { nodes, edges: newEdges };
} else {
return prev;
}
Expand Down Expand Up @@ -362,6 +567,7 @@ export const Drawing = (props: Props) => {
<DragIcon drawingMode={drawingMode} />
<NodeModal
node={selectedNode}
graph={graph}
onChange={handleChangeNode}
onCancel={handleClearSelectedNode}
/>
Expand Down

0 comments on commit 6202bda

Please sign in to comment.