compiled = URL.createObjectURL(
new Blob(
[
`// includes performance tricks
// calculations are performed monthly (the input widget lerps between handles)
export const month = ({ month_in }) => month_in;
// map to an array of monthly rates
export const month_annual_rate_table_fn = ({ month_annual_rate_table_fn_in }) => month_annual_rate_table_fn_in;
export const v = ({ v_in }) => v_in; // interactivity performance trick: memoize by a version number integer instead of table of annual rates; see also custom memo hash below
export const monthly_rate$ = ({ month_annual_rate_table_fn_in, month_in, v_in }) => {
return Math.pow(1 + month_annual_rate_table_fn({ month_annual_rate_table_fn_in })()[month({ month_in })].rate, 1 / 12) - 1;
v({ v_in }); // see above, this makes calculang recognise a dependency and recalc values as v changes
};
export const price$ = ({ month_in, month_annual_rate_table_fn_in, v_in }) => {
if (month({ month_in }) == 0) return 1;else
return (
price({ month_annual_rate_table_fn_in, v_in, month_in: month({ month_in }) - 1 }) * (
1 + monthly_rate({ month_annual_rate_table_fn_in, v_in, month_in: month({ month_in }) - 1 })));
};
// custom memo hash function for better perf/memory tradeoffs vs JSON.stringify default:
export const memo_hash$ = ({ month_annual_rate_table_fn_in, ...o }) => Object.values(o);
// memoization
export const monthly_rate$m = memoize(monthly_rate$, memo_hash$);
export const monthly_rate = (a) => {
return monthly_rate$m(a);
// eslint-disable-next-line no-undef
monthly_rate$({ month_annual_rate_table_fn_in, month_in, v_in }); // never run, but here to "trick" calculang graph logic
};
// memoization
export const price$m = memoize(price$, memo_hash$);
export const price = (a) => {
return price$m(a);
// eslint-disable-next-line no-undef
price$({ month_in, month_annual_rate_table_fn_in, v_in }); // never run, but here to "trick" calculang graph logic
};
// memoization
export const memo_hash$m = memoize(memo_hash$, memo_hash$);
export const memo_hash = (a) => {
return memo_hash$m(a);
// eslint-disable-next-line no-undef
memo_hash$({}); // never run, but here to "trick" calculang graph logic
};
// from https://cdn.jsdelivr.net/npm/underscore@1.13.6/underscore-esm.js
// Memoize an expensive function by storing its results.
function memoize(func, hasher) {
var memoize = function(key) {
var cache = memoize.cache;
var address = '' + (hasher ? hasher.apply(this, arguments) : key);
if (!has$1(cache, address)) cache[address] = func.apply(this, arguments);
return cache[address];
};
memoize.cache = {};
return memoize;
}
// Internal function to check whether \`key\` is an own property name of \`obj\`.
function has$1(obj, key) {
return obj != null && Object.prototype.hasOwnProperty.call(obj, key);
}
`
],
{ type: "text/javascript" }
)
)
model = await import(compiled)
cartesian = (...a) =>
a.reduce((a, b) => a.flatMap((d) => b.map((e) => [d, e].flat())))
//viewof domainMax = Inputs.select([48 + 2, 12, 13, 14]);
domainMax = 50
dots = cartesian(
_.range(
0,
domainMax - 2,
1
) /*[..._.range(0,domainMax,12),..._.range(11,domainMax,12)]*/,
[..._.range(0, 0.15001, 0.01 / 1), ..._.range(-0.01 / 1, -0.07001, -0.01 / 1)]
).map(([year, rate]) => ({ year, rate }));
//mywidth = width
spec = ({
//
// NOTE: I should put everything in groups, but it happens to be a working structure and some of the overlap is ok
/*
Some of the names of things:
Data sources:
- handle_values: keeps a handle value (or null) for each month
- handle_values_not_null: just filters above for not null, needed to calculate...
- interpolated_values: the interpolated values; populated with rates for every month
fed to calculang model to calculate prices
- prices: result from calculang model; populated via Vega view API below
- dots: this is very important: populated with combo of all choices of month-rates
Fed to a voronoi transform; resultant path marks are then where all interactivity is triggered.
I didn't try straight rect marks; possibly a good idea (computation for all this should happen only once though)
Signals:
- set/unset_handle_value triggered by dots selection: maintains handle_values
Note: 'year' is used but it should be month or time
VEGA-SCENEGRAPH PATCH::
Necessary to ship a hacky change in https://github.com/vega/vega/pull/3547/files
for proper interactivity (touch drag) to work on mobile
*/
$schema: "https://vega.github.io/schema/vega/v5.json",
autosize: "fit",
description:
"A basic bar chart example, with value labels shown upon pointer hover.",
width: width - 30,
background: "white",
height: 450,
padding: 5,
data: [
{
name: "handle_values", // init data
values: [
{ year: 0, rate: 0.1 },
..._.range(1, domainMax).map((year) => ({ year, rate: null }))
],
on: [
{
trigger: "set_handle_value",
modify: "data('handle_values')[set_handle_value.year]", // assuming 0 index in year
values: "{year:set_handle_value.year, rate:set_handle_value.rate}"
},
{
trigger: "unset_handle_value",
modify: "data('handle_values')[unset_handle_value.year]", // assuming 0 index in year
values: "{year:unset_handle_value.year, rate:null}"
}
]
},
{
name: "interpolated_values", // how much slowness does this cause on mobile without calculang? not a lot!
source: "handle_values",
transform: [
{
type: "formula",
expr: "if(datum.rate != null, datum.year, null)",
as: "year_not_null"
},
{
type: "filter",
expr: "datum.rate != null || 1"
},
{ type: "window", ops: ["next_value"], fields: ["rate"] },
{
type: "window",
ops: ["prev_value"],
fields: ["rate", "year_not_null"]
},
{ type: "window", ops: ["prev_value"], fields: ["year_not_null"] },
{ type: "window", ops: ["next_value"], fields: ["year_not_null"] },
{
type: "formula",
expr: "if(datum.next_value_rate==null, datum.prev_value_rate, lerp([datum.prev_value_rate,datum.next_value_rate],(datum.year-datum.prev_value_year_not_null)/(datum.next_value_year_not_null-datum.prev_value_year_not_null)))",
as: "interpolated"
}
]
},
{
name: "prices",
values: _.range(0, domainMax).map((d, i) => ({
year: `${d}`,
amount: 1 /** (1+i*0.1)*/
}))
},
{
name: "handle_values_not_null",
source: "handle_values",
transform: [{ type: "filter", expr: "datum.rate != null" }]
},
{
name: "prices_at_handle_values", // effective: note lag year_plus_1
source: "handle_values_not_null",
transform: [
{
type: "formula",
as: "year_plus_1",
expr: "datum.year+1"
},
{
type: "lookup",
from: "prices",
key: "year",
as: ["price"],
fields: ["year_plus_1"]
}
]
},
{
name: "dots",
values: dots,
transform: [
// x, y a re fields in voronoi, use that ?!?!
{ type: "formula", as: "x", expr: "scale('xscale', datum.year)" }, // voronoi transform requires this !
{
type: "formula",
as: "y",
expr: "scale('handle_yscale', datum.rate)"
},
{
type: "voronoi",
x: "x",
y: "y",
size: [{ signal: "width" }, { signal: "top_height" }] //extent: [[0,0], [mywidth,150]]//, size: {signal:"width"}
}
]
}
],
/*"signals": [
{
"name": "tooltip",
"value": {},
"on": [
{"events": "rect:pointerover", "update": "datum"},
{"events": "rect:pointerout", "update": "{}"}
]
}
],*/
scales: [
{
name: "xscale",
//"type": "band",
domain: { data: "prices", field: "year" },
range: "width",
padding: 30
//"round": true
},
{
name: "handle_yscale",
domain: [-0.08, 0.16], //.66 here is great - do veronoi and do row facet
//"domain": {"data": "prices", "field": "amount"},
nice: false,
//"range": [0,500]//"height"
range: [150, 0]
}
],
axes: [
//{ "orient": "bottom", "scale": "xscale", title: 'start of month -', values: [0,12,24,36,48,domainMax-2], labelFontSize: 14, labelFontWeight:'bold', labelColor: 'gray', titleColor: 'gray', grid:true },
{
orient: "top",
scale: "xscale",
title: "month",
values: [0, 12, 24, 36, 48, domainMax - 2],
labelFontSize: 14,
//labelColor: "gray",
//titleColor: "gray",
grid: true
},
//{ "orient": "right", "scale": "yscale", grid: true, gridWidth:4, gridColor: 'pink', values:[1,1.25,1.5], titleColor: 'hotpink'/*, title: 'prices'*/, format:'%', labelFontSize: 15, labelColor:'pink', titleFontSize: 20 },
{
orient: "left",
scale: "handle_yscale",
values: [0, 0.05, 0.1, -0.05, 0.15],
labelFont: "monospace",
labelColor: "steelblue",
format: ".0%",
labelFontSize: 16 /*, title: 'inflation rate'*/,
titleColor: "steelblue",
titleFontSize: 20,
grid: true,
labelFontWeight: "bold"
}
],
signals: [
{ name: "top_height", value: 150 },
{ name: "spacing", value: 30 },
{
name: "set_handle_value", // add click to remove a handle?
on: [
{
// cell vs dots
//"events": "@cell:click[!event.shiftKey], [@cell:mousedown, window:mouseup] > @cell:mousemove",
events:
"@cell:click[!event.shiftKey], @cell:touchstart!, @cell:touchmove!{50}, [@cell:pointerdown[!event.shiftKey], window:pointerup] > @cell:pointermove!",
//"events": "@cell:touchmove",
update: "datum"
}
]
},
/* {name: "create"x_handle_debounced",
on: [{
events
}]}*/
{
name: "unset_handle_value", // add click to remove a handle?
on: [
{
// cell vs dots
// see triggers in https://vega.github.io/vega/docs/transforms/voronoi/
events:
"@cell:click[event.shiftKey], @cell:dblclick, [@cell:pointerdown[event.shiftKey], window:pointerup] > @cell:pointermove",
update: "datum"
}
]
}
],
marks: [
//,
/*{
"type": "symbol",
name: 'dots',
"from": {"data":"dots"},
"encode": {
"enter": {
shape: {value: 'square'},
//x: {value: 8},
"x": {"scale": "xscale", field: 'year'},
"y": {"scale": "handle_yscale", "field": "rate"},
"fill": {value: 'red'},//{"signal": "datum.rate > 0 ? 'lightgreen' : datum.rate == 0 ? 'lightblue' : 'pink'"},
size: {value: 400},
},
"update": {
//"opacity": {"signal": "if(datum.actual_handle_rate==null, 0, if(abs(datum.actual_handle_rate - datum.rate)<0.05,0.4,0) )"},
},
}
},*/
/*
{
"type": "text",
"encode": {
"enter": {
"align": {"value": "center"},
"baseline": {"value": "bottom"},
"fill": {"value": "#333"}
},
"update": {
"x": {"scale": "xscale", "signal": "tooltip.year", "band": 0.5},
"y": {"scale": "yscale", "signal": "tooltip.amount", "offset": -2},
"text": {"signal": "tooltip.amount"},
"fillOpacity": [
{"test": "datum === tooltip", "value": 0},
{"value": 1}
]
}
}
}*/
{
//"type": "path", //
type: "symbol",
interactive: false,
from: { data: "handle_values_not_null" },
encode: {
enter: {
shape: { value: "circle" },
//path: {value: 'M -3 0 L -3 -6 L -9 -6 L 0 -18 L 9 -6 L 3 -6 L 3 6 L 9 6 L 0 15 L -9 6 L -3 6 Z'},
//x: {value: 8},
stroke: { value: "black" },
x: { scale: "xscale", field: "year" },
size: { value: 100 }
},
update: {
y: { scale: "handle_yscale", field: "rate" },
fill: {
signal:
"datum.rate > 0 ? 'lightgreen' : datum.rate == 0 ? 'lightblue' : 'pink'"
}
}
/*"hover": { // better to overlay actual handles. Voronoi in the way ??
size: {value: 2800},
}*/
}
},
{
type: "line",
from: { data: "interpolated_values" },
interactive: false,
encode: {
enter: {
//strokeCap: {value: 'butt'}, // rendering bugs
//strokeCap: {value: 'round'},
//strokeDash: {value: [1,6]},
interpolate: { value: "step-after" },
stroke: { value: "red" },
strokeWidth: { value: 3 },
x: { scale: "xscale", signal: "datum.year-0.5" },
size: { value: 70 },
opacity: { value: 0.8 }
},
update: {
y: { scale: "handle_yscale", field: "interpolated" }
}
}
},
// NOT OPTIMISED
{
type: "group",
encode: {
enter: {
x: { value: 0 },
y: { value: 0 },
width: { signal: "width" }, // should be update
height: { signal: "top_height" },
stroke: { value: "steelblue" },
strokeWidth: { value: "4" }
}
},
marks: [
{
type: "text",
interactive: false,
encode: {
enter: {
x: { value: 10 },
y: { value: 5 },
baseline: { value: "top" },
text: { value: "inflation rate p.a." },
fill: { value: "steelblue" },
opacity: { value: 0.8 },
fontSize: { value: 20 },
fontWeight: { value: "bold" },
fontStyle: { value: "italic" }
}
}
},
{
interactive: false,
type: "rect",
encode: {
enter: {
x: { value: 0 },
y: { scale: "handle_yscale", value: 0 },
width: { signal: "width" },
y2: { scale: "handle_yscale", value: -0.08 },
fill: { value: "grey" },
opacity: { value: 0.1 }
}
}
},
{
type: "path",
name: "cell",
from: { data: "dots" },
encode: {
enter: {
fill: { value: "transparent" },
opacity: { value: 0.01 },
path: { field: "path" },
stroke: { value: "blue" }
}
}
}
]
},
{
type: "group",
axes: [
{
orient: "right",
scale: "yscale",
grid: true,
//gridWidth: 1,
//gridColor: "pink",
values: [1, 1.2, 1.5, 0.8, 0.5],
titleColor: "pink" /*, title: 'prices'*/,
format: "%",
labelFontSize: 17,
labelColor: "purple",
labelFontWeight: "bold",
labelFont: "monospace",
titleFontSize: 20,
strokeWidth: 10,
encode: {
ticks: {
enter: { stroke: null }
},
domain: {
enter: { stroke: null }
}
}
},
{
orient: "bottom",
scale: "xscale",
encode: {
domain: {
enter: { stroke: null }
},
ticks: {
enter: { stroke: null }
}
},
//title: "start of month -",
values: [0, 12, 24, 36, 48, domainMax - 2],
labelFontSize: 10
//labelFontWeight: "bold"
//labelColor: "gray",
//titleColor: "gray",
//grid: true
}
],
scales: [
{
name: "yscale",
domain: [0.75, 1.5],
//domain: { data: "prices", field: "amount" },
zero: false,
//"nice": true,
//"range": "height"
range: [{ signal: "height - top_height - spacing" }, 0]
}
],
encode: {
enter: {
x: { value: 0 },
y: { signal: "top_height + spacing" },
width: { signal: "width" }, // these should be update
height: { signal: "height-top_height-spacing" }
//stroke: { value: "orange" },
//strokeWidth: { value: "1" }
}
},
marks: [
{
type: "text",
encode: {
enter: {
x: { signal: "width/2" }, // move to update
y: { value: 10 },
text: { value: "=> price" },
fill: { value: "purple" },
fontSize: { value: 30 },
fontWeight: { value: "bold" },
fontStyle: { value: "italic" },
//font: { value: "monospace" }
align: { value: "right" }
}
}
},
{
//"type": "path", //
type: "symbol",
interactive: false,
from: { data: "prices_at_handle_values" },
encode: {
enter: {
shape: { value: "square" },
//path: {value: 'M -3 0 L -3 -6 L -9 -6 L 0 -18 L 9 -6 L 3 -6 L 3 6 L 9 6 L 0 15 L -9 6 L -3 6 Z'},
//x: {value: 8},
//stroke: { value: "black" },
x: { scale: "xscale", field: "year_plus_1" },
size: { value: 80 },
opacity: { value: 0.6 }
},
update: {
y: { scale: "yscale", field: "price.amount" },
fill: {
signal:
"datum.rate > 0 ? 'lightgreen' : datum.rate == 0 ? 'lightblue' : 'pink'"
}
}
/*"hover": { // better to overlay actual handles. Voronoi in the way ??
size: {value: 2800},
}*/
}
},
{
type: "line",
//tooltip: true,
interactive: false,
from: { data: "prices" },
encode: {
enter: {
/*tooltip: {
signal:
"{'Price': format(datum.amount, '0.1%'), 'start of month': datum.year}"
},*/
//strokeCap: {value: 'butt'}, // rendering bugs
//strokeCap: {value: 'round'},
//strokeDash: {value: [1,6]},
//interpolate: { value: "step-after" },
stroke: { value: "purple" },
strokeWidth: { value: 4 }, // 6
//dy: { value: -30 },
x: { scale: "xscale", signal: "datum.year-0.5" }, // use band instead?
size: { value: 70 },
y: { scale: "yscale", field: "amount" } //
}
}
},
,
]
}
]
})
viewof viz = embed_(spec, { renderer: "canvas", actions:false });
mutable v_in = 0;
mutable rates = viz.data("interpolated_values");
function update() {
mutable v_in++;
model.price$m.cache = {};
model.monthly_rate$m.cache = {};
mutable rates = viz.data("interpolated_values");
};
a = viz.addSignalListener("set_handle_value", (_) => {
update();
});
b = viz.addSignalListener("unset_handle_value", (_) => {
update();
});
// known BUG: interactions lost on screen rotation change
// this messes visibility of scales? even with .resize()
//viz.signal('width', width-80).run()
zz = viz // give a name just to mitigate output
.data(
"prices",
_.range(0, domainMax).map((d, i) => ({
year: `${d}`,
amount: model.price({
v_in,
month_in: d,
month_annual_rate_table_fn_in: () =>
rates.map((d) => ({ year: d.year, rate: d.interpolated }))
})
}))
)
//.resize()
.run();