Skip to content
Browse files

Loans replaced with loan collections.

This means that user can build any combination of loans into a combined loan-collection. This is useful to test the curves of real world situations where people often have multiple mortgages on a single property. The current Icelandic landscape also includes mixed loans of indexed/unindexed parts and this would be a requirement to calculating them.

This now adds the need to show collection breakdowns as small multiples.

As it turns out I am starting to regret some design decisions, it would have been much less complicated to classify the loan collection and loans into a model. The current structure made more sense when the form was static. I've added a bunch of helper methods to reduce the work of dealing with it, but I may need to refactor the whole thing if any more complexity is added.
  • Loading branch information...
1 parent 5b12a6b commit b44956067551e67b17314133450824cb688a8941 @borgar committed Aug 12, 2012
Showing with 282 additions and 99 deletions.
  1. +22 −16 css/styles.less
  2. +81 −60 index.html
  3. +172 −22 js/app.js
  4. +7 −1 js/jquery.to_query.js
View
38 css/styles.less
@@ -21,6 +21,7 @@ select {
input {
padding : 3px 4px;
box-shadow: 1px 1px 5px #ccc inset;
+ border-radius : 3px;
}
h1 {
margin : 0;
@@ -76,25 +77,30 @@ fieldset {
clear: both;
}
-#loan1, #loan2 {
- float : left;
- clear: none;
- width : 49.5%;
- -moz-box-sizing: border-box;
- box-sizing: border-box;
- select {
- width : 65%;
- }
- .formcontrol label {
- display: inline-block;
- width : 30%;
- text-align : right;
- }
+
+table {
+ width : 100%;
+}
+th, td {
+ text-align : left;
+ padding : 4px 0;
+ border-bottom: 1px solid #ccc;
}
-#loan2 {
- float : right;
+td.actions {
+ text-align : right;
}
+.buttons {
+ padding : 6px 0;
+ text-align : right;
+ margin-bottom: 2px;
+}
+
+tr:only-child button[data-action] {
+ visibility: hidden;
+}
+
+
fieldset.inactive {
color : #999;
input, option, select {
View
141 index.html
@@ -21,72 +21,93 @@
<div id="col1">
- <fieldset class="" id="loan1">
- <legend><label><input type="checkbox" id="loan1_on" checked> Lán 1:</label></legend>
- <div class="formcontrol">
- <label for="loan1_principal">Lánsfjárhæð:</label>
- <input id="loan1_principal" name="loan1_principal" size="10"
- value="20000000" min="1000000" step="1000000" type="number" />
- <abbr class="unit" title="krónur">kr.</abbr>
- </div>
- <div class="formcontrol">
- <label for="loan1_period">Tímabil:</label>
- <input id="loan1_period" name="loan1_period" size="2"
- value="40" max="60" min="5" step="1" type="number" />
- <span class="unit">ár</span>
+ <fieldset class="" id="l1wrap">
+ <legend><label><input type="checkbox" id="l1" checked> Lánasafn 1:</label></legend>
+ <input id="l1n" name="l1n" type="hidden" value="1">
+
+ <table cellspacing="0">
+ <thead>
+ <tr>
+ <th>&nbsp;</th>
+ <th>Lánsfjárhæð</th>
+ <th>Tímabil</th>
+ <th>Tegund láns</th>
+ <th>Vextir</th>
+ <th>&nbsp;</th>
+ </tr>
+ </thead>
+ <tbody id="l1body">
+
+ <tr id="l1(1)row">
+ <td class="formcontrol formselect formcheck">
+ <label>
+ <input id="l1(1)" name="l1(1)" value="on" type="checkbox" checked />
+ </label>
+ </td>
+ <td class="formcontrol">
+ <input id="l1(1)principal" name="l1(1)principal" size="10"
+ value="20000000" min="1000000" step="1000000" type="number" />
+ <abbr class="unit" title="krónur">kr.</abbr>
+ </td>
+ <td class="formcontrol">
+ <input id="l1(1)period" name="l1(1)period" size="2"
+ value="40" max="60" min="5" step="1" type="number" />
+ <span class="unit">ár</span>
+ </td>
+ <td class="formcontrol formselect">
+ <select id="l1(1)type" name="l1(1)type">
+ <option value="idx_eq">Verðtryggt - Jafnar afborganir</option>
+ <option value="idx_pmt">Verðtryggt - Jafngreiðslur</option>
+ <option value="eq">Óverðtryggt - Jafnar afborganir</option>
+ <option value="pmt">Óverðtryggt - Jafngreiðslur</option>
+ </select>
+ </td>
+ <td class="formcontrol">
+ <input id="l1(1)interest" name="l1(1)interest" size="2"
+ value="5" max="25" min="1" step="1" type="number" />
+ <abbr class="unit" title="prósent">%</abbr>
+ </td>
+ <td class="actions">
+ <button data-action="delete-loan">✖</button>
+ </td>
+ </tr>
+
+ </tbody>
+ </table>
+
+ <div class="buttons">
+ <button data-action="add-loan">Bæta við láni</button>
</div>
- <fieldset class="">
- <div class="formcontrol formselect">
- <label for="loan1_type">Tegund láns:</label>
- <select id="loan1_type" name="loan1_type">
- <option value="idx_eq">Verðtryggt - Jafnar afborganir</option>
- <option value="idx_pmt">Verðtryggt - Jafngreiðslur</option>
- <option value="eq">Óverðtryggt - Jafnar afborganir</option>
- <option value="pmt">Óverðtryggt - Jafngreiðslur</option>
- </select>
- </div>
- <div class="formcontrol">
- <label for="loan1_interest">Vextir:</label>
- <input id="loan1_interest" name="loan1_interest" size="2"
- value="5" max="25" min="1" step="1" type="number" />
- <abbr class="unit" title="prósent">%</abbr>
- </div>
- </fieldset>
+
</fieldset>
- <fieldset class="" id="loan2">
- <legend><label><input type="checkbox" id="loan2_on"> Lán 2:</label></legend>
- <div class="formcontrol">
- <label for="loan2_principal">Lánsfjárhæð:</label>
- <input id="loan2_principal" name="loan2_principal" size="10"
- value="20000000" min="1000000" step="1000000" type="number" />
- <abbr class="unit" title="krónur">kr.</abbr>
- </div>
- <div class="formcontrol">
- <label for="loan2_period">Tímabil:</label>
- <input id="loan2_period" name="loan2_period" size="2"
- value="40" max="60" min="5" step="1" type="number" />
- <span class="unit">ár</span>
+
+ <fieldset class="" id="l2wrap">
+ <legend><label><input type="checkbox" id="l2"> Lánasafn 2:</label></legend>
+ <input id="l2n" name="l2n" type="hidden" value="1">
+
+ <table cellspacing="0">
+ <thead>
+ <tr>
+ <th>&nbsp;</th>
+ <th>Lánsfjárhæð</th>
+ <th>Tímabil</th>
+ <th>Tegund láns</th>
+ <th>Vextir</th>
+ <th>&nbsp;</th>
+ </tr>
+ </thead>
+ <tbody id="l2body"></tbody>
+ </table>
+
+ <div class="buttons">
+ <button data-action="add-loan">Bæta við láni</button>
</div>
- <fieldset class="">
- <div class="formcontrol formselect">
- <label for="loan2_type">Tegund láns:</label>
- <select id="loan2_type" name="loan2_type">
- <option value="idx_eq">Verðtryggt - Jafnar afborganir</option>
- <option selected value="idx_pmt">Verðtryggt - Jafngreiðslur</option>
- <option value="eq">Óverðtryggt - Jafnar afborganir</option>
- <option value="pmt">Óverðtryggt - Jafngreiðslur</option>
- </select>
- </div>
- <div class="formcontrol if_index">
- <label for="loan2_interest">Vextir:</label>
- <input id="loan2_interest" name="loan2_interest" size="2"
- value="5" max="25" min="1" step="1" type="number" />
- <abbr class="unit" title="prósent">%</abbr>
- </div>
- </fieldset>
+
</fieldset>
+
+
<fieldset class="" id="economy">
<legend>Verðþróun:</legend>
<div class="formcontrol">
View
194 js/app.js
@@ -4,6 +4,37 @@ numfmt.locale['is'] = {
, decimal_separator : ','
};
+jQuery.fn.collectionId = function ( nid ) {
+ if ( nid ) {
+ return this.each(function () {
+ this.id = this.id.replace( /^l\d+/, nid );
+ if ( this.name ) {
+ this.name = this.name.replace( /^l\d+/, nid );
+ }
+ });
+ }
+ var id = this[0] ? this[0].id : '';
+ return id.replace( /^(l\d+).*/, '$1' );
+};
+
+jQuery.fn.loanId = function ( nid ) {
+ if ( nid ) {
+ return this.each(function () {
+ this.id = this.id.replace( /^l\d+\(\d+\)/, nid );
+ if ( this.name ) {
+ this.name = this.name.replace( /^l\d+\(\d+\)/, nid );
+ }
+ });
+ // this.id = id.replace( /^l\d+\(\d+\)/, nid );
+ return this;
+ }
+ var id = this[0] ? this[0].id : '';
+ var i = Number( id.replace( /^.+\((\d+)\).*$/, '$1' ) );
+ if ( !isFinite( i ) ) { i = 0; };
+ return i;
+};
+
+
// run app
jQuery(function ($) {
@@ -16,6 +47,12 @@ jQuery(function ($) {
var current_data;
var current_driver;
+ // get loan row template
+ var tmplElm = $( 'table tr[id$=\\)row]' );
+ var loan_tmpl = $( '<div></div>' ).append( tmplElm[0] ).html().split( /l\d\(\d\)/ );
+ tmplElm.remove(); delete tmplElm;
+
+
// these are called by the amortizer to calculate each payment
// the init/calculate functions are called with this === loan, first parameter === payment row
var loan_drivers = {
@@ -143,20 +180,61 @@ jQuery(function ($) {
}
};
+ // helper functions to get/put data into form
+ function update_loans ( id ) {
+ var loans = 0;
+ $( '#' + id + 'wrap' )
+ .find( 'tr[id^='+id+']' )
+ .each(function () {
+ var pre = id + '(' + (loans++) + ')';
+ $( this ).loanId( pre )
+ .find( '*[id^='+id+'\\(]' ).loanId( pre );
+ })
+ ;
+ $( '#' + id + 'n' ).val( loans );
+ }
+
+ function add_loan ( c ) {
+ $( '#' + c + 'body' ).append( loan_tmpl.join( c + '(0)' ) );
+ }
+
+ function loans_count ( c, n ) {
+ var elm = $( '#' + c + 'n' );
+ if ( n != null ) {
+ elm.val( n );
+ }
+ else {
+ n = Number( elm.val() );
+ if ( !isFinite( n ) || !n ) { n = 0; };
+ }
+ return n;
+ }
+
// read the properties of a loan for the UI and return a customized "driver"
- function get_loan_properties ( loan_elm_id ) {
- var type = $( loan_elm_id + '_type' ).val();
+ function get_loan_properties ( id, collection ) {
+ var type = $( '#' + id + 'type' ).val();
var driver = $.extend( {}, loan_drivers[ type ] );
- driver.principal = Number( $( loan_elm_id + '_principal' ).val() || 0 );
- driver.interest = Number( $( loan_elm_id + '_interest' ).val() || 0 ) / 100;
- driver.period = Number( $( loan_elm_id + '_period' ).val() || 1 );
- driver.inflation = Number( $( '#inflation' ).val() || 0 ) / 100;
- driver.active = $( loan_elm_id + "_on" )[ 0 ].checked;
- driver.in_current_value = $( '#value' )[ 0 ].checked;
+ driver.principal = Number( $( '#' + id + 'principal' ).val() || 0 );
+ driver.interest = Number( $( '#' + id + 'interest' ).val() || 0 ) / 100;
+ driver.period = Number( $( '#' + id + 'period' ).val() || 1 );
+ driver.active = collection.active && $( '#' + id )[ 0 ].checked;
+ driver.inflation = collection.inflation;
+ driver.in_current_value = collection.in_current_value;
return driver;
}
+ function get_collection_properties ( id ) {
+ var collection = {};
+ collection.active = $( '#' + id )[ 0 ].checked;
+ collection.inflation = Number( $( '#inflation' ).val() || 0 ) / 100;
+ collection.in_current_value = $( '#value' )[ 0 ].checked;
+ collection.loans = pv.range( loans_count( id ) ).map(function (i) {
+ return get_loan_properties( id + '\\(' + i + '\\)', collection );
+ });
+ return collection;
+ }
+
// amortization
function amortize ( loan ) {
@@ -192,6 +270,47 @@ jQuery(function ($) {
return loan;
}
+ function sum_loans ( collection ) {
+
+ // make a copy of the first active one
+ var period = 0;
+ var principal = 0;
+ var loans = collection.loans.filter(function(d){
+ if ( d.active ) {
+ if ( d.period > period ) {
+ period = d.period;
+ }
+ principal += d.principal;
+ }
+ return d.active;
+ });
+ var payments = [];
+ for (var p=0; p<period; p++) {
+
+ var payment = $.extend( {}, loans[0].payments[p] );
+ for (var li=1,ll=loans.length; li<ll; li++) {
+ var px = loans[li].payments[p];
+ if ( px ) {
+ payment.amount_payed += px.amount_payed;
+ payment.capital_payment += px.capital_payment;
+ payment.interest += px.interest;
+ payment.payment_upcalc += px.payment_upcalc;
+ payment.period_captial_end += px.period_captial_end;
+ payment.period_captial_start += px.period_captial_start;
+ }
+ }
+ payments.push( payment );
+ }
+ var r = {
+ active: !!payments.length,
+ in_current_value: collection.loans[0].in_current_value,
+ payments: payments,
+ period: period,
+ principal: principal
+ };
+ return r;
+ }
+
function plot ( loans, display_driver ) {
@@ -208,7 +327,7 @@ jQuery(function ($) {
display_driver.init.call( loan );
}
if ( !loan.active ) { return []; }
- var series = loan.payments
+ var series = loan.payments
.map( display_driver.calculate, loan )
.filter(function ( d ) {
if ( isFinite(d.x) && isFinite(d.y) ) {
@@ -228,6 +347,7 @@ jQuery(function ($) {
})
;
+
// prevent floating point artifacts from triggering axis
if ( Math.abs( y_min ) > 0 && Math.abs( y_min ) < 1e-8 ) {
y_min = 0;
@@ -248,7 +368,7 @@ jQuery(function ($) {
, margin_right = 10
, margin_bottom = 20
, margin_left = 65
- , w = fit_space - (margin_left + margin_right) // 480
+ , w = fit_space - ( margin_left + margin_right ) // 480
, aspect = 1 / 1.618
, h = w * aspect
, x_scale = pv.Scale.linear( x_min, x_max )
@@ -424,31 +544,35 @@ jQuery(function ($) {
}
// Plot
- var loan1 = amortize( get_loan_properties( "#loan1" ) );
- $( '#loan1' )
- .css( 'border-color', loan1.active ? colors[0] : '' )
- .toggleClass( 'inactive', !loan1.active )
+ var collection1 = get_collection_properties( 'l1' );
+ collection1.loans.forEach( amortize );
+ $( '#l1wrap' )
+ .css( 'border-color', collection1.active ? colors[0] : '' )
+ .toggleClass( 'inactive', !collection1.active )
;
- var loan2 = amortize( get_loan_properties( "#loan2" ) );
- $( '#loan2' )
- .css( 'border-color', loan2.active ? colors[1] : '' )
- .toggleClass( 'inactive', !loan2.active )
+ var collection2 = get_collection_properties( 'l2' );
+ collection2.loans.forEach( amortize );
+ $( '#l2wrap' )
+ .css( 'border-color', collection2.active ? colors[1] : '' )
+ .toggleClass( 'inactive', !collection2.active )
;
- current_data = [ loan1, loan2 ];
+ // TODO: fork in the road - small multiples for collections, or one with both?
+ current_data = [ sum_loans( collection1 ), collection2.loans[0] ];
plot( current_data, current_driver );
// serialize to hash
// NOTE: don't do this naiively because ppl may put their income in, change to something else
// and then share the URL - this would then be leaking their income.
var cq = [ '#calcview', '#economy' ];
- cq.push( loan1.active ? '#loan1' : '#loan1 legend' );
- cq.push( loan2.active ? '#loan2' : '#loan2 legend' );
+ cq.push( collection1.active ? '#l1wrap' : '#l1wrap legend' );
+ cq.push( collection2.active ? '#l2wrap' : '#l2wrap legend' );
if ( current_driver.extra_controls ) {
cq.push( '#' + current_driver.extra_controls );
}
- document.location.hash = '!' + $( cq.join(', ') ).to_query();
+ var q = $( cq.join(', ') ).to_query();
+ document.location.hash = q ? '!' + q : '';
}
// hook updates to pretty much all UI events that do anything
@@ -458,6 +582,21 @@ jQuery(function ($) {
.bind( 'submit', update_app )
.bind( 'blur', update_app )
.bind( 'focusout', update_app )
+ .on( 'click', 'button[data-action=add-loan]', function (e) {
+ var c_id = $( e.target ).closest( 'fieldset' ).collectionId();
+ add_loan( c_id );
+ update_loans( c_id );
+ update_app();
+ })
+ .on( 'click', 'button[data-action=delete-loan]', function (e) {
+ var row = $( e.target ).closest( 'tr' );
+ var c_id = row.collectionId();
+ if ( loans_count( c_id ) > 1 ) {
+ row.remove();
+ update_loans( c_id );
+ update_app();
+ }
+ })
;
// window resize handler
@@ -479,6 +618,17 @@ jQuery(function ($) {
var hash = String( document.location.hash ).replace( /^#?!?/, '' );
$( '#inputform' ).apply_query( hash );
+ [ 'l1', 'l2' ].forEach(function (id) {
+ for (var l=0,n=loans_count( id ); l<n; l++) {
+ add_loan( id );
+ }
+ update_loans( id );
+ });
+
+ $( '#inputform' ).apply_query( hash );
+
+
+
// trigger initial rendering
update_app();
View
8 js/jquery.to_query.js
@@ -23,6 +23,9 @@
if ( ctrl.type === 'checkbox' ) {
val = ctrl.defaultChecked ? ctrl.value : '';
}
+ else if ( ctrl.type === 'hidden' ) {
+ val = ''; // hidden values don't track defaults
+ }
// TODO: radio
else if ( ctrl.type === 'select-one' ) {
var d = -1;
@@ -100,7 +103,10 @@
if ( this.type === 'checkbox' ) {
this.checked = this.defaultChecked;
}
- else {
+ else if ( this.type === 'hidden' ) {
+ //this.value = getDefaultValue( this );
+ }
+ else {
this.value = getDefaultValue( this );
}
}

0 comments on commit b449560

Please sign in to comment.
Something went wrong with that request. Please try again.