Visualizing Risk
visualization
actuarial
Featuring a pension savings calculation, Monte Carlo simulation, and ‘scrollytelling’
Author
Published
July 1, 2024
For an interactive pension savings calculator, I’m aiming to illustrate some value of compound interest, employer matching, tax relief, and to visually highlight the impact of changes to calculation inputs like starting age or investment growth rate.
But every illustration it produces suggests a smooth path to a retirement fund.
Reality isn’t so smooth, however. In a few parts I’ll focus on this, but I’ll zoom into one uncertain feature among others first: risky investments mean risky outcomes.
Here we’ll work towards a visualization to interpret uncertain outcomes - in other words to visualize risk.
To go there: just scroll!
My pension savings calculator suggests a smooth path to a retirement fund 🌈💰
But pension savings usually involve risky investments
Risky investments do not have smooth paths! 📈
So, a savings path might rather look like this:
or this:
By assuming a distribution (or range of values) for investment growth: rather than a single value
then we can simulate many paths
and again:
In a barchart 📊 we can track where simulated paths land
And simulate again… keep scrolling! showing simulations
Now we can visualize risk with help from this barchart 📊
The barchart shows us a distribution of outcomes 📊
The average simulated result is close (ish!) to the original - ‘smooth’ result
but many of our paths landed far below that! 😱
For example 🕵️
Code
md`Below ↕️ <span style="color:black; font-weight:bold; border-bottom: 1px dashed grey">${llf}</span>, <span style="color:red; font-weight:bold">${ll2} paths</span> of ${num_simulations} land: or <span style="font-weight:bold">${pcc}</span> of them
<br/>
Their average retirement fund is **${d3.format('.3s')(d3.mean(lows2_values))}**: *significantly lower than <span style="color: green">**${d3.format('.3s')(expd.find(d => d.age_in==65).value)}**</span>*
`
We just scanned ↕️ the downside tail of our outcome distribution
For risk analysis, it can be useful to test results here against our risk appetite
Risk
To visualize risk - to do anything with risk, it’s important to consider many scenarios: not just one (and not even just one exercise).
What we visualized above is a very simple form of Monte Carlo simulation.
Risk is not necessarily bad - and it can be mitigated or managed: talk to your financial adviser. This blog post is not financial advice, and it shouldn’t be used for financial advice.
As far as pension savings go: thankfully, on a long savings journey it’s normal to have lots of ways to control risk.
Notably - choosing investments that suit your risk appetite.
Almost certainly this should change as you approach retirement. Above: it doesn’t.
But maybe in another blog post it will 💭
assumptions & calculation notes 🔍
Here is a plot of some samples from the investment growth rate per annum distribution:
Code
I didn’t do a lot of science to determine these assumptions, or other notable ones that are fixed:
- Person (‘employee’) starts saving aged 25, to retire at 65
- Salary at age 25 is 30k, which grows at 2% per annum until retirement
- Person saves 10% of salary per annum
- Employer matches 50% of contributions: so that effectively 15% of persons salary is contributed per annum
Charges:
- Contribution charge of 4% is taken from all contributions (one-off)
- Management charge of 1% is taken from accumulated fund per annum (annual)
You can follow this link (better on a desktop) for the calculang formulas and for a visualization of cashflows.
- In the visualization:
employee contributions
are split intotax relief
(based on an Irish income tax calculation) andcost
.
It’s the sum and not this split that’s used for the retirement fund calculations (so Irish income tax calculations are effectively not relevant here)
versus pension savings calculator 🤼♂️
Code
md`Populating these fixed assumption values and \`6%\` for investment growth assumption into the pension savings calculator provides a retirement fund projection that matches the "original <span style="color:green; font-weight:bold">'smooth' result</span>" of <span style="color:green; font-weight:bold">731k</span> (<span style="color:green; font-weight:bold">731k</span> above before rounding is **${d3.format(',.2f')(expd.find(d => d.age_in==65).value)}**) 🟰`
- We expect this: here we did monte carlo simulation using the same calculang model 🔗 (not ‘Copy of..’ etc.)
You can use this link to validate that (better on a desktop: same link as above) , and for a visualization of the cashflows behind 731k - which you can also download.
⚠️ caveats relating to that model also apply here
models, model outputs warning
The assumptions, methodology and limitations of a model and model outputs should be carefully considered for any purpose you apply them to.
I haven’t completely listed these - or otherwise properly documented models and outputs.
🙏 Making this post was a chance to finally experiment with certain interaction and visualization techniques that are firsts for me - and I hope useful for explanation and communication about numbers, models, and modelling techniques.
Please report issues you encounter, and get in touch if you’d like to provide some feedback!
ignore this (will delete)
Code
dist_spec = ({
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"data": {values: unit_growth_rates},
"config": {
"view": {"stroke": "transparent"}
},
width: 200,
height: 90,
layer: [
{
"mark": {type: "bar",binSpacing:0},
"config": {
"view": {"stroke": "transparent"}
},
"encoding": {
/*color: {
//aggregate: 'count',
field: 'value',
bin: {step: .02},
"type": "quantitative",
scale: {domainMid: 0, "range": "diverging", domain: "unaggregated"}
},*/
//tooltip: {value: 'hi'},
"x": {
"bin": {step: .03},
"field": "value",
axis: {
orient: 'top',
labelFont:'monospace',
grid: true,values: [
0,
cursor.unit_growth_rate_mean_in,
cursor.unit_growth_rate_mean_in + 2*cursor.unit_growth_rate_std_dev_in,
cursor.unit_growth_rate_mean_in - 2*cursor.unit_growth_rate_std_dev_in,
], format:'%',
labelFontSize: 12, title:'investment growth rate p.a.'}
},
"y": {"aggregate": "count", axis:null}
}
},
{
transform: [{filter: 'datum.value <= 0'}],
"mark": {type: "bar", binSpacing:0},
"config": {
"view": {"stroke": "transparent"}
},
"encoding": {
color: {value:'orange'},
"x": {
"bin": {step: .03},
"field": "value",
},
"y": {"aggregate": "count", axis:null}
}
},
/*{
"mark": {type: "rule", size:3},
"encoding": {
x: {datum:0},
color: {value:'#b36200'},
}
},
{
"mark": {type: "rule", size: 1, strokeOpacity: 0.3},
"encoding": {
x: {datum:cursor.unit_growth_rate_mean_in},
color: {value:'darkblue'},
}
}*/
]
})
embed('#dist1',dist_spec, {renderer: 'canvas', actions:false})
Code
cursor = ({
age_0_in: 25,
retirement_age_in: 65,
fund_value_0_in: 0,
empee_contribution_rate_in: 0.1,
emper_matching_rate_in: 0.5,
salary_0_in: 30000,
salary_age_0_in: 25,
salary_inflation_rate_in: 0.02,
contribution_charge_rate_in: 0.04,
management_charge_rate_in: 0.01,
missed_contribution_age_in: 0,
random_seed_in: 'na',
actual_unit_growth_rates_co_in: 70,
lifestyling_in: false,
unit_growth_rate_mean_in: 0.06,
unit_growth_rate_std_dev_in: 0.1
})
limit = 3e6
Code
expd = helpers.calcudata({
models: [model],
input_domains: {
age_in: _.range(cursor.age_0_in-1,65.01),
},
input_cursors: [{...cursor, actual_unit_growth_rates_co_in:0, simulation_in:0}],
outputs: [v],
});
unit_growth_rates = helpers.calcudata({
models: [model],
input_domains: {
age_in: _.range(cursor.age_0_in-1,65.01),
simulation_in: _.range(0,200) // limited sims here
},
input_cursors: [cursor],
outputs: ['unit_growth_rate'],
});
fund_value = helpers.calcudata({
models: [model],
input_domains: {
age_in: _.range(cursor.age_0_in-1,65.01),
simulation_in: _.range(0,simulations),
},
input_cursors: [cursor],
outputs: [v],
})
fixed_ranges = v == 'fund_value'
step = v == 'fund_value' ? 100000*1 : 0.01
Code
viz = embed('#viz', {
//data: {values: fund_value},
//padding: 40,
data: { name: "data" },
params: [
{ name: "actual_unit_growth_rates_co_in", value: 30 },
{ name: "simulation", value: 5 },
{ name: "scare_opacity", value: 0 },
{ name: "tolerance", value: 1e6 },
{ name: "interactive_lhs", value: true } // really "show text"
],
transform: [
{
formula: 'datum.simulation_in+" "+datum.lifestyling_in',
as: "details"
},
{ filter: "datum.simulation_in <= simulation" },
{
calculate: "random()",
as: "rand"
},
{
calculate: `simulation < 5 ? 0.5
: simulation < 10 ? 0.5
: simulation < 20 ? 0.3
: simulation < 50 ? 0.25
: simulation < 100 ? 0.2
: simulation < 200 ? 0.15
: simulation < 300 ? 0.12
: simulation < 350 ? 0.1
: simulation < 400 ? 0.08
: simulation < 450 ? 0.06 : 0.05
`, //"0.1" /*"1 ||simulation < 10 ? 0.5 : 0.01"*/,
as: "opacity"
}
],
hconcat: [
{
title: "fund value 📈",
width:
width - 50 - 100 - 100 /*-40*/ /*padding*/ - 60 * 0 /*axis fonts*/,
height: "container",
//padding: 90,
encoding: {
x: {
field: "age_in",
title: "Age",
scale: { domain: _.range(20, 69) },
axis: { values: [25, 40, 55, 65], labelAngle: 0 }
}
},
layer: [
{
transform: [
{ filter: "datum.simulation_in!=simulation" },
{ filter: "datum.actual_unit_growth_rates_co_in!=0" },
{ calculate: "interactive_lhs", as: "interactive_lhs2" } // have to bring it into the layer to use it??
],
mark: {
type: "line",
clip: true,
tooltip: false,
point: false,
//strokeWidth: 4,
strokeCap: "round"
},
encoding: {
strokeWidth: {
legend: false,
field: "interactive_lhs2",
type: "ordinal",
scale: { domain: [false, true], range: [1, 5] }
//scale: { domain: [true, false], range: [5, 1] }
},
detail: {
field: "simulation_in",
type: "nominal",
legend: false
},
y: {
field: "value",
title: null,
type: "quantitative",
scale: fixed_ranges ? { domain: [0, limit] } : {}
},
color: { value: "pink" },
opacity: {
field: "opacity",
type: "quantitative",
legend: null,
scale: { domain: [0, 1], range: [0, 1] }
}
}
},
{
transform: [
//{ filter: "datum.simulation_in!=simulation" },
{ filter: "datum.actual_unit_growth_rates_co_in==0" },
{ filter: "datum.age_in==65" }
],
mark: {
type: "text",
clip: false,
tooltip: false,
xOffset: 20
},
encoding: {
text: { value: "💰" },
detail: {
field: "simulation_in",
type: "nominal",
legend: false
},
xOffset: {
field: "rand",
type: "quantitative",
legend: null,
scale: { domain: [0, 1], range: [15, 22] }
},
y: {
field: "value",
title: null,
type: "quantitative",
scale: fixed_ranges ? { domain: [0, limit] } : {}
},
opacity: {
field: "opacity",
type: "quantitative",
legend: null,
scale: { domain: [0, 1], range: [0, 1] }
},
size: {
value: 20
}
}
},
{
transform: [
//{ filter: "datum.simulation_in!=simulation" },
{ filter: "datum.actual_unit_growth_rates_co_in==0" },
{ filter: "datum.age_in==65" },
{
filter:
"datum.simulation_in!=simulation || actual_unit_growth_rates_co_in >= 66 || datum.actual_unit_growth_rates_co_in == 0"
}
],
mark: {
type: "text",
clip: false,
tooltip: false,
xOffset: -50,
fontWeight: "bold"
},
encoding: {
text: {
field: "value",
format: v == "fund_value" ? ".3s" : ".2%"
},
detail: {
field: "simulation_in",
type: "nominal",
legend: false
},
y: {
field: "value",
title: null,
type: "quantitative",
scale: fixed_ranges ? { domain: [0, limit] } : {}
},
opacity: {
value: 0.4
},
color: {
value: "green"
},
size: {
value: 22
}
}
},
{
transform: [{ filter: "datum.actual_unit_growth_rates_co_in==0" }], // expd,
mark: {
type: "line",
clip: true,
tooltip: false,
point: false,
strokeWidth: 6,
strokeCap: "round"
},
encoding: {
detail: {
field: "simulation_in",
type: "nominal",
legend: false
},
y: {
field: "value",
title: "pension fund",
type: "quantitative",
scale: fixed_ranges ? { domain: [0, limit] } : {}
},
opacity: { value: 0.5 },
color: { value: "green" }
}
},
{
transform: [
{ filter: "datum.actual_unit_growth_rates_co_in!=0" },
{ filter: "datum.age_in < actual_unit_growth_rates_co_in" },
{ filter: { param: "simulations", empty: false } },
{ calculate: "interactive_lhs", as: "interactive_lhs2" } // have to bring it into the layer to use it??
//{filter: {param: 'simulations'}}
],
mark: {
type: "line",
clip: true,
tooltip: false,
point: false,
//strokeWidth: 4,
strokeCap: "round"
},
encoding: {
strokeWidth: {
field: "interactive_lhs2", // value here is silent danger
type: "ordinal",
scale: { domain: [false, true], range: [1, 5] }
},
detail: {
field: "simulation_in",
type: "nominal",
legend: false
},
y: {
field: "value",
title: "pension fund",
type: "quantitative",
scale: fixed_ranges ? { domain: [0, limit] } : {},
axis:
v == "fund_value"
? {
grid: false,
format: ".2s",
values: [
/*1e6,1.4e6*/
]
}
: {}
},
opacity: {
field: "interactive_lhs2", // value here is silent danger
type: "ordinal", legend:false,
scale: { domain: [false, true], range: [0.5, 1] }
},
color: { value: "red" } // => color conditions obsolete TODO
}
},
{
transform: [
//{ filter: "datum.simulation_in!=simulation" },
{ filter: "interactive_lhs == true" },
{ filter: "datum.actual_unit_growth_rates_co_in!=0" },
{ filter: "datum.age_in==65" },
{ filter: { param: "simulations", empty: false } },
{
filter:
"datum.simulation_in!=simulation || actual_unit_growth_rates_co_in >= 66 || datum.actual_unit_growth_rates_co_in == 0"
}
],
mark: {
type: "text",
clip: false,
tooltip: false,
xOffset: -30,
yOffset: 1,
fontWeight: "bold"
},
encoding: {
text: {
field: "value",
format: v == "fund_value" ? ".3s" : ".2%"
},
detail: {
field: "simulation_in",
type: "nominal",
legend: false
},
y: {
field: "value",
title: null,
type: "quantitative",
scale: fixed_ranges ? { domain: [0, limit] } : {}
},
opacity: {
value: "0.1",
condition: { param: "simulations", value: 1, empty: false }
},
color: {
value: "black"
},
size: {
value: 22
}
}
},
{
transform: [
//{ filter: "datum.simulation_in!=simulation" },
{ filter: "interactive_lhs == true" },
{ filter: "datum.actual_unit_growth_rates_co_in!=0" },
{ filter: "datum.age_in==65" },
{ filter: { param: "simulations", empty: false } },
{
filter:
"datum.simulation_in!=simulation || actual_unit_growth_rates_co_in >= 66 || datum.actual_unit_growth_rates_co_in == 0"
}
],
mark: {
type: "text",
clip: false,
tooltip: false,
xOffset: -31,
fontWeight: "bold"
},
encoding: {
text: {
field: "value",
format: v == "fund_value" ? ".3s" : ".2%"
},
detail: {
field: "simulation_in",
type: "nominal",
legend: false
},
y: {
field: "value",
title: null,
type: "quantitative",
scale: fixed_ranges ? { domain: [0, limit] } : {}
},
opacity: {
value: "0.1",
condition: { param: "simulations", value: 1, empty: false }
},
color: {
value: "orange"
},
size: {
value: 21
}
}
},
{
transform: [
//{ filter: "datum.simulation_in!=simulation" },
{ filter: "datum.actual_unit_growth_rates_co_in!=0" },
{ filter: "datum.age_in==65" },
{
filter:
"datum.simulation_in!=simulation || actual_unit_growth_rates_co_in >= 66 || datum.actual_unit_growth_rates_co_in == 0"
}
],
mark: {
type: "text",
clip: false,
tooltip: false,
xOffset: 20
},
params: [
{
name: "simulations",
select: {
type: "point",
//nearest: true,
//clear: "pointerup",
toggle: true,
resolve: "union", // doing this and keeping 2 lines as tricky to get away from emptying selection on interaction with chart?
//on: "pointerover, interactive_lhs",
on: "pointerover",
fields: ["simulation_in"]
}
}
],
encoding: {
text: { value: "💰" },
detail: {
field: "simulation_in",
type: "nominal",
legend: false
},
xOffset: {
field: "rand",
type: "quantitative",
legend: null,
scale: { domain: [0, 1], range: [15, 22] }
},
y: {
field: "value",
title: null,
type: "quantitative",
scale: fixed_ranges ? { domain: [0, limit] } : {}
},
opacity: {
field: "opacity",
type: "quantitative",
legend: null,
scale: { domain: [0, 1], range: [0, 1] },
condition: { param: "simulations", value: 1, empty: false }
},
size: {
value: 20,
condition: { param: "simulations", value: 22, empty: false }
}
}
},
{
transform: [
{
filter:
"datum.actual_unit_growth_rates_co_in==0 && datum.age_in == 65"
},
{ calculate: "tolerance", as: "tolerance2" },
{ calculate: "65", as: "l" },
{ calculate: "66", as: "r" }
], // only one mark needed
mark: { type: "rule", tooltip: false, size: 2, clip: true },
encoding: {
y: {
field: "tolerance2",
//aggregate: "mean",
/*value: 1e6,
*/ type: "quantitative", // why do I need scale here and in channels below I don't?
scale: fixed_ranges
? { domain: [0, limit], range: ["height", 0] }
: {}
//value: 100
},
//y2: { value: 2000 },
x: {
field: "l"
/*title: "Age",
scale: { domain: _.range(20, 69) },
axis: { values: [25, 40, 55, 65], labelAngle: 0 }*/
},
x2: {
field: "r"
/*title: "Age",
scale: { domain: _.range(20, 69) },
axis: { values: [25, 40, 55, 65], labelAngle: 0 }*/
},
/*x: {
value: 55
},*/
//x2: { value: 66 },
color: { value: "red" },
opacity: { value: 0.8 }
}
},
]
},
,
/*{
width: 50,
height: 500, //"container",
title: "💰",
layer: [
{
transform: [
{ filter: "datum.actual_unit_growth_rates_co_in!=0" },
{
filter:
"datum.simulation_in!=simulation || actual_unit_growth_rates_co_in >= 66"
},
{
filter: `datum.age_in == 65 || ${v == "unit_growth_rate" && 0}`
}
],
mark: { type: "point", clip: true, filled: false, size: 100 },
encoding: {
shape: { value: "square" },
color: {
value: "orange",
condition: { param: "simulations", value: "red" }
},
opacity: {
value: 0.2,
condition: { param: "simulations", value: 1 }
},
y: {
field: "value",
type: "quantitative",
scale: fixed_ranges ? { domain: [0, limit] } : {},
axis: null
}
}
},
{
transform: [
{ filter: "datum.actual_unit_growth_rates_co_in==0" },
{ filter: "datum.age_in == 65" }
],
mark: {
type: "point",
clip: true,
filled: false,
size: 100,
xOffset: -2
},
encoding: {
shape: { value: "square" },
color: { value: "blue" },
opacity: { value: 0.5 },
y: {
field: "value",
type: "quantitative",
scale: fixed_ranges ? { domain: [0, limit] } : {},
axis: null
}
}
},
{
transform: [
{ filter: "datum.actual_unit_growth_rates_co_in!=0" },
{
filter:
"datum.simulation_in!=simulation || actual_unit_growth_rates_co_in >= 66"
},
{
filter: `datum.age_in == 65 || ${v == "unit_growth_rate" && 0}`
}, //],
{ filter: { param: "simulations" } }
],
mark: { type: "point", clip: true, filled: false, size: 100 },
encoding: {
shape: { value: "square" },
color: { value: "red" },
opacity: { value: 1 },
y: {
field: "value",
type: "quantitative",
scale: fixed_ranges ? { domain: [0, limit] } : {},
axis: null
}
}
}
]
}*/ {
width: 100,
height: "container",
title: "📊",
transform: [
//{filter: 'datum.actual_unit_growth_rates_co_in!=0'},
{
filter:
"datum.simulation_in!=simulation || actual_unit_growth_rates_co_in >= 66 || datum.actual_unit_growth_rates_co_in == 0"
},
{ filter: `datum.age_in == 65 || ${v == "unit_growth_rate"}` }
],
encoding: {
//y: { field: 'value', "bin": {"step": step}, type: 'quantitative', scale: { domain: [0,limit] }, axis: null }
},
layer: [
{
transform: [{ filter: "datum.actual_unit_growth_rates_co_in!=0" }],
mark: {
type: "bar",
clip: true,
tooltip: false /*, interpolate:'step-after'*/
},
encoding: {
x: {
aggregate: "count",
title: "#",
//scale: {domain:[0,20]},
/*scale: {domain: [0,120]},*/ axis: { grid: false, tickCount: 2 }//, scale: {nice:false}
},
opacity: {
//value: 0.3,
condition: { param: "simulations", value: 1, empty: false }
},
y: {
field: "value",
bin: { step: step },
type: "quantitative",
scale: fixed_ranges ? { domain: [0, limit] } : {},
axis: {
format: ".2s",
title: null,
values: [1e6, 0.5e6],
labelAngle: 90
}
},
//y: { field: 'value', "bin": {"step": step}, type: 'quantitative', scale: { domain: [0,limit] } },
//color: {value: 'orange'}
detail: { field: "simulation_in" },
color: {
value: "pink",
condition: { param: "simulations", value: "red", empty: false }
}
/*strokeWidth: {
value: 0,
condition: { param: "simulations", value: 1 }
},
stroke: { value: "black" }*/
}
},
/*{
transform: [{ filter: "datum.actual_unit_growth_rates_co_in==0" }],
mark: { type: "rule", tooltip: false, size: 3 },
encoding: {
opacity: { value: 0.5 },
y: { field: "value", aggregate: "sum" },
color: { value: "green" }
}
},*/
/*{
transform: [{ filter: "datum.actual_unit_growth_rates_co_in!=0" }],
mark: { type: "rule", tooltip: false },
encoding: {
y: { aggregate: "median", field: "value" },
opacity: { value: 0.2 },
color: { value: "orange" }
}
},*/
{
transform: [{ filter: "datum.actual_unit_growth_rates_co_in!=0" }],
mark: { type: "rule", tooltip: false, size: 3 },
encoding: {
y: { aggregate: "mean", field: "value" },
color: { value: "steelblue" },
opacity: { value: 0.5 }
}
},
{
transform: [{ filter: "datum.actual_unit_growth_rates_co_in!=0" }],
mark: {
type: "text",
tooltip: false,
xOffset: 20,
yOffset: -7,
fontSize: 17,
fontWeight: "bold"
},
encoding: {
text: {
aggregate: "mean",
field: "value",
format: v == "fund_value" ? ".3s" : ".2%"
},
y: { aggregate: "mean", field: "value" },
color: { value: "steelblue" }
}
},
{
//transform: [{ filter: "datum.actual_unit_growth_rates_co_in!=0" }],
transform: [{ calculate: "tolerance", as: "tolerance3" }],
mark: { type: "rule", tooltip: false, size: 4, style: "abc", clip: true },
encoding: {
y: { aggregate: "mean", field: "tolerance3" },
color: { value: "black" },
opacity: { value: 0.9 }
}
},
{
transform: [{ calculate: "tolerance", as: "tolerance3" }],
mark: {
type: "text",
tooltip: false,
xOffset: 27-4,
yOffset: 11,
fontSize: 16, clip: true,
fontStyle: 'italic',
fontWeight: "bold"
},
encoding: {
text: {
aggregate: "mean",
field: "tolerance3",
format: v == "fund_value" ? ".3s" : ".2%"
},
y: { aggregate: "mean", field: "tolerance3" },
color: { value: "black" }
}
},
{
transform: [{ calculate: "tolerance", as: "tolerance3" }],
mark: {
type: "text",
tooltip: false,
xOffset: -5,
yOffset: 12,
fontSize: 16, clip: true,
//fontStyle: 'italic',
fontWeight: "bold"
},
encoding: {
text: {
value: "↕️" // should I use 'datum'?
},
y: { aggregate: "mean", field: "tolerance3" },
color: { value: "black" }
//opacity: { value: 0.5 }
}
},
{
transform: [
{ filter: "datum.actual_unit_growth_rates_co_in==0" },
{ calculate: "scare_opacity", as: "scare_opacity1" }
],
mark: { type: "text", fontSize: 80 },
encoding: {
text: { datum: "😱" },
opacity: { field: "scare_opacity1", legend: false, type:'quantitative', scale: { domain: [0, 1], range: [0, 1] }
},
x: { value: 60 },
y: { datum: 4e5 }
}
}
]
}
].filter((d) => d != null)
}, {/*theme:'fivethirtyeight',*/ config: {
axis: {
grid: false,
////titleFontSize:20,
//labelFontSize:40
},
title: {
anchor: 'middle',
fontSize: 20,
fontWeight: 600,
//offset: 50,
//yOffset: 200
},
}
})
Code
Code
Code
{
//viz.addSignalListener("brush", (b, e, f) => {
//console.log("values", e.value);
//if (e.value[0] == undefined) debugger;
let e = { value: [0, ll] };
viz.view.signal("simulations", { simulation_in: [], vlPoint: { or: [] } });
viz.view.data("simulations_store", [
{
unit: "concat_1",
fields: [{ type: "E", field: "simulation_in" }],
values: [0]
},
{
unit: "concat_0_layer_3",
fields: [{ type: "E", field: "simulation_in" }],
values: []
}
]);
// viz.view.run()
//console.log("hiyy2", e.value[0], lows2);
viz.view.signal("simulations", {
simulation_in: lows2,
vlPoint: {
or: lows2.map((d) => ({ simulation_in: d }))
}
});
//.run();
viz.view.data("simulations_store", [
{
unit: "concat_1",
fields: [{ type: "E", field: "simulation_in" }],
values: [] // dummy?
},
...lows2.map((d) => ({
unit: "concat_0_layer_3",
fields: [{ type: "E", field: "simulation_in" }],
values: [d]
}))
]);
//console.log(viz.signal("simulations_tupple", false));
viz.view.runAsync();
//});
}
Code
Code
{
viz.view.signal('simulation', simulation);
//viz.view.run()
viz.view.signal('simulations', {simulation_in:[simulation],vlPoint:{or:[{simulation_in:simulation}]}});
viz.view.data('simulations_store', [{unit:'concat_1', fields: [{type:'E', field:'simulation_in'}], values:[simulation]}]);
viz.view.run()
}
Code
num_simulations = 500
viewof simulation = Inputs.range([0,simulations-1], {value:0, label:'simulation', step: 1})
viewof simulations = Inputs.range([1,num_simulations], {value:num_simulations, label:'simulations', step: 1})
viewof actual_unit_growth_rates_co_in = Inputs.range([19,66], {value:0, label:'actual_unit_growth_rates_co_in', step: 1})
viewof v = Inputs.select(['fund_value','unit_growth_rate'], {label:'variable'})
model init stuff
Code
Code
fs = ({
"entrypoint.cul.js": await FileAttachment('./cul/rec.cul.js').text(),
"./monte-carlo.cul.js": await FileAttachment('./cul/monte-carlo.cul.js').text(),
"https://calculang.dev/models/taxes-pensions/pension-calculator.cul.js": await (await fetch('https://calculang.dev/models/taxes-pensions/pension-calculator.cul.js')).text(),
"./simple-incometax.cul": await (await fetch('https://calculang.dev/models/taxes-pensions/simple-incometax.cul.js')).text(),
})
introspection = await calculang.introspection('entrypoint.cul.js', fs)
compiled = await calculang.compile_new('entrypoint.cul.js', fs, introspection) // ?
esm = compiled[0]
bundle = calculang.bundleIntoOne(compiled, introspection, true)
u = URL.createObjectURL(new Blob([bundle], { type: "text/javascript" }))
console.log(`creating ${u}`)
Code
Code
scrollama
Code
scrollama = require("scrollama")
scroller = scrollama();
mutable inhibit = false
// https://github.com/russellsamora/scrollama/issues/145#issuecomment-1764582014
//debounce = require("throttle-debounce").debounce //
window.onresize = () => {mutable inhibit.value = true; scroller.resize(); mutable inhibit.value = false}
Code
scroller
.setup({
progress: true,
step: ".step",
debug: false
})
.onStepProgress(response => {
const path = +response.element.dataset.path
const isPath = !isNaN(path)
set(viewof progress, response.progress)
set(viewof step_, response.index + ' ' + response.direction)
//console.log(response)
//console.log(isPath)
//debugger
//if ([2,3,5,6].includes(response.index)) {
if (isPath) {
set(viewof actual_unit_growth_rates_co_in, Math.floor(25+response.progress*(66-25)))
}
/*if (response.index == 5) {
set(viewof simulation, Math.floor(response.progress*3)+1)
set(viewof actual_unit_growth_rates_co_in, Math.floor(25+response.progress*(66-25)))
}*/
if (response.element.dataset.step == 'all') {
if (response.progress > 0.78)
set(viewof simulation, num_simulations-1)
else if (response.progress > 0.71) // clamp these for untested screen res?
set(viewof simulation, num_simulations-51)
else if (response.progress > 0.65) // clamp these for untested screen res?
set(viewof simulation, num_simulations-101)
else if (response.progress > 0.58)
set(viewof simulation, num_simulations-151)
else if (response.progress > 0.50)
set(viewof simulation, num_simulations-201)
else if (response.progress > 0.03) {
set(viewof simulation, Math.min(num_simulations-1,Math.max(3,Math.floor(d3.scalePow([0.1,0.8], [3,num_simulations]).exponent(3)(response.progress)))))
}
else {
viz.view.signal('simulations', {simulation_in:[],vlPoint:{or:[]}});
viz.view.data('simulations_store', [{unit:'concat_1', fields: [{type:'E', field:'simulation_in'}], values:[]}]);
viz.view.run()
}
}
if (response.element.dataset.step == 'visualize') {
set(viewof opacities2, d3.scaleLinear([0,0.5],[1,0]).clamp(true)(Math.abs(response.progress-0.5)))
}
if (response.element.dataset.step == 'scare') {
set(viewof scare_opacity, d3.scaleLinear([0,0.5],[1,0]).clamp(true)(Math.abs(response.progress-0.5)))
}
//visualize
if (response.element.dataset.step == 'track') {
set(viewof opacities, d3.scaleLinear([0.2,0.9],[0,1])(response.progress))
}
if (response.element.dataset.step == 'belowA') {
/*if (viz.view.signal('interactive_lhs') )
viz.view.signal('interactive_lhs', false).run()*/
set(viewof ll, Math.max(260000,Math.min(0.5e6,Math.round(d3.scaleLinear([0.5,0.8],[260000,500000]).clamp(true)(response.progress)*1)/1)))
}
//document.getElementById('progress').innerHTML = document.getElementById('progress2').innerHTML = d3.format('.2%')(response.progress) + ' ' + response.direction + ' ' + response.index
})
.onStepEnter((response) => { // resize Always triggers Enter and Exit
if (!inhibit) {
if ([3,4/*distn*/,8,9/*all simulations*/, 12 /* scary */, 14 /* tolerance */, 15 /* final slide */].includes(response.index) && window.plausible)
window.plausible('viz-risk progress', { props: {index: response.index}})
const path = +response.element.dataset.path
const isPath = !isNaN(path)
if (response.element.dataset.step == 'example') {
//if (viz.view.signal('interactive_lhs') )
viz.view.signal('interactive_lhs', false).run()
set(viewof ll, 260000)
}
if (response.element.dataset.step == 'track' || response.element.dataset.step == 'visualize') {
viz.view.signal('simulations', {simulation_in:[],vlPoint:{or:[]}});
viz.view.data('simulations_store', [{unit:'concat_1', fields: [{type:'E', field:'simulation_in'}], values:[]}]);
viz.view.run()
}
if (isPath) {
set(viewof simulation, path)
set(viewof actual_unit_growth_rates_co_in, Math.floor(25+0*(66-25)))
}
/*if (response.element.dataset.step == 'belowA') {
set(viewof ll, 260000)
viz.view.signal('interactive_lhs', false)
viz.view.run()
}*/ // visual artifacts on scroll events, so put into progress
// the box is passed to this function so we can change it!
//console.log("Enter triggered")
//console.log(response)
// { element, index, direction }
response.element.classList.add("is-active");
/*if (response.element.dataset.step == 'all' && response.direction == 'up') {
viz.view.signal('interactive_lhs', true)
viz.view.run()
}*/ // moved back to exit
}
})
.onStepExit((response) => {
/*if ((response.index == 0) && (response.direction == 'up')) {
set(viewof simulation, 0)
set(viewof actual_unit_growth_rates_co_in, Math.floor(25+0*(66-25)))
}*/ // temp disable, is this issue on mobile?
//console.log("Exit triggered", response)
//console.log(response)
// { element, index, direction }
//response.element.classList.remove("is-active");
/*if (!inhibit) {
if (response.element.dataset.step == 'belowA' && response.direction == 'up') {
viz.view.signal('interactive_lhs', true)
set(viewof ll, -1e5)
viz.view.run()
}
}*/
});