I would like to build an Angular app that would ask questions to a user and generate the next question according to their answer (following a decision tree).
For example (I'll expose multiple scenarios, each one on one line, questions in italic and answers in bold):
CHEST PAIN ?
yes/ \no
RATE?
<60 / \ >100
HYPO ?
no/ \ yes
**SEDATE**(result of the decision tree)
Could you please give me some clues on how to achieve that? This looks like a tree (or a graph). Should I store my questions in such a data structure?
Does something like this work for you?
public readonly countries = ["France", "Germany"];
public readonly cities = {
"France": ["Paris", "Lyon", "Marseille"],
"Germany": ["Berlin", "Frankfurt", "Hamburg"]
};
public selection = {
country: '',
city: ''
};
Pick country
<select [(ngModel)]="selection.country">
<option *ngFor="let c of countries" [ngValue]="c">{{c}}</option>
</select>
<br/>
<br/>
<ng-container *ngIf="selection.country.length > 0">
Pick city
<select [(ngModel)]="selection.city">
<option *ngFor="let c of cities[selection.country]" [ngValue]="c">{{c}}</option>
</select>
</ng-container>
Starts from an initial top level selection and then shows the next question depending on the previous answer.
UPDATE 1: I've adapted the answer based on the diagram you posted. The new solution does not use hard coded paths, instead defines a "decision tree" structure and uses that. I'll explain in more detail later, but the essence is that you only need to modify the contents of one variable and the app will still work. New stackblitz.
The idea is storing each node in your tree as an object like this:
export class TreeNode {
public readonly id: nodeId;
public readonly description: string;
public decision: boolean | null;
public readonly yesId: nodeId | null;
public readonly noId: nodeId | null;
constructor(
id: nodeId,
description: string,
yesId: nodeId | null,
noId: nodeId | null
) {
this.id = id;
this.description = description;
this.decision = null; // <-- must be null on creation. wait for decision from user.
this.yesId = yesId;
this.noId = noId;
}
}
id
is a string, should be unique among all nodes. More on that later.description
is the question/answer body.decision
is the result of the Yes/No question.yesId
is the id
of the node we should navigate to, when the decision is yes.noId
is the id
of the node we should navigate to, when the decision is no.
If a node is a leaf, then yesId
and noId
should both be null.I kept all possible nodes from your drawing as a "dictionary" called nodeList
, so we can access a node using nodeList[id]
syntax. It looks like this now:
/**
* Possible values for a node id. This is optional, but highly encouraged.
*/
export type nodeId =
| "chestPain"
| "twaveInversion"
| "rateOver100"
| "hypotensive"
| "nstemi"
| "unstableAngina"
| "sedate"
| "qrsOver012"
| "vtGetExpertHelp"
| "qrsRegular"
| "svtVagal"
| "pwavesPresent"
| "atrialFibrilation"
| "aflutter"
| "rate100temp";
/**
* Dictionary of nodes using their id as a key.
*/
export const nodeList = {
chestPain: new TreeNode(
"chestPain",
"Chest Pain?",
"twaveInversion",
"rateOver100"
),
twaveInversion: new TreeNode(
"twaveInversion",
"Twave Inversion?",
"nstemi",
"unstableAngina"
),
unstableAngina: new TreeNode("unstableAngina", "Unstable Angina", null, null),
nstemi: new TreeNode("nstemi", "NSTEMI", null, null),
rateOver100: new TreeNode(
"rateOver100",
"Rate > 100?",
"hypotensive",
"rate100temp"
),
hypotensive: new TreeNode(
"hypotensive",
"Hypotensive?",
"sedate",
"qrsOver012"
),
sedate: new TreeNode("sedate", "Sedate.", null, null),
qrsOver012: new TreeNode(
"qrsOver012",
"QRS > 0.12s ?",
"vtGetExpertHelp",
"qrsRegular"
),
vtGetExpertHelp: new TreeNode(
"vtGetExpertHelp",
"VT Get expert help.",
null,
null
),
qrsRegular: new TreeNode(
"qrsRegular",
"QRS regular?",
"svtVagal",
"pwavesPresent"
),
svtVagal: new TreeNode("svtVagal", "SVT Vagal", null, null),
pwavesPresent: new TreeNode(
"pwavesPresent",
"Pwaves present?",
"aflutter",
"atrialFibrilation"
),
aflutter: new TreeNode("aflutter", "Aflutter", null, null),
atrialFibrilation: new TreeNode(
"atrialFibrilation",
"Afrial fibrilation",
null,
null
),
rate100temp: new TreeNode(
"rate100temp",
"Rate < 100 answer (no node given)",
null,
null
)
};
And finally, the DecisionTreeFormComponent
:
export class DecisionTreeFormComponent implements OnInit {
public decisionTree: IDecisionTree;
public currentNode: TreeNode;
public treeJSONhidden: boolean = true;
public nodeJSONhidden: boolean = true;
constructor() {}
ngOnInit() {
this.reset();
}
public reset() {
// Init base node and tree here.
this.decisionTree = [];
this.currentNode = Object.assign({}, nodeList.chestPain);
}
public yes() {
this.currentNode.decision = true;
this.pushNode();
this.currentNode = Object.assign({}, nodeList[this.currentNode.yesId]);
if( this.isFinal(this.currentNode)) {
this.pushNode();
}
}
public no() {
this.currentNode.decision = false;
this.pushNode();
this.currentNode = Object.assign({}, nodeList[this.currentNode.noId]);
if( this.isFinal(this.currentNode)) {
this.pushNode();
}
}
public isFinal = (node: TreeNode) => node.yesId == null && node.noId == null;
private pushNode():void {
this.decisionTree.push({
node: this.currentNode,
index: this.decisionTree.length
});
}
}
In the reset()
method I simply initialize the currentNode
to the chest pain question and the rest is handled by the user's selections. I keep the path as an array to display the final result when a leaf node is reached. Check out the sample HTML I've prepared in the stackblitz, but the presentation is irrelevant in this case. You can tweak it to suit your needs.
I think this is an OK solution, where you only need to change the nodeList
and the form will work based on the values it finds there.
Limitations: This currently only works for YES/NO type questions. If you expect multiple selections, you can try to "refactor" the questions to be multiple true/false questions. Otherwise, we can discuss that further.