[html-chart] add html-chart.scm to access ChartJS

This is very similar to (but not 100% drop-in replacement) jqplot.
This commit is contained in:
Christopher Lam 2018-09-16 20:50:13 +08:00
parent 847c531fdd
commit 24550714f3
7 changed files with 629 additions and 0 deletions

View File

@ -61,6 +61,7 @@ set (report_system_SCHEME_2b
set (report_system_SCHEME_3
commodity-utilities.scm
html-acct-table.scm
html-chart.scm
html-barchart.scm
html-document.scm
html-fonts.scm

View File

@ -0,0 +1,489 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; html-chart.scm : generate HTML programmatically, with support
;; for simple style elements.
;;
;; Added dependency on guile-json to help construct options. Migrated
;; from obsolete jquery and jqplot to modern chartjs instead in 2018
;; by Christopher Lam
;;
;; This program is free software; you can redistribute it and/or
;; modify it under the terms of the GNU General Public License as
;; published by the Free Software Foundation; either version 2 of
;; the License, or (at your option) any later version.
;;
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;;
;; You should have received a copy of the GNU General Public License
;; along with this program; if not, contact:
;;
;; Free Software Foundation Voice: +1-617-542-5942
;; 51 Franklin Street, Fifth Floor Fax: +1-617-542-2652
;; Boston, MA 02110-1301, USA gnu@gnu.org
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(use-modules (json builder)) ;for building JSON options
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;
;; utility functions for nested list handling
;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; nested-alist-set! parameters are
;; lst - a nested alist e.g. (list (cons 'key1 'val1)
;; (cons 'key2 (list (cons 'key2-sub1 'val2a))))
;; path - a list of symbols or single-element list
;; e.g. '(key2 key2-sub1)
;; '(key2 key2-sub2 (0))
;; '(key3 key3-sub1 key3-sub1-sub1)
;; newval - the cdr of the innermost cons cell specified by the path
;;
;; see test-html-chart.scm for usage examples
(define (nested-alist-set! lst path newval)
(define (path->nested-alist path newval)
(let innerloop ((path (reverse path))
(result newval))
(cond
((null? path)
result)
((and (pair? (car path)) (number? (caar path)))
(innerloop (cdr path)
(let ((v (make-vector (1+ (caar path)))))
(vector-set! v (caar path) result)
v)))
(else
(innerloop (cdr path)
(list (cons (car path) result)))))))
(let loop ((nested-lst lst) (path path))
(cond
((or (null? nested-lst) (null? path))
(throw "invalid state"))
((and (pair? (car path)) (number? (caar path)))
(cond
((>= (caar path) (vector-length nested-lst))
(throw (format #f "high vector index ~s must be set earlier"
(caar path))))
((list? (vector-ref nested-lst (caar path)))
(loop (vector-ref nested-lst (caar path))
(cdr path)))
(else
(vector-set! nested-lst
(caar path)
(path->nested-alist (cdr path) newval)))))
(else
(let ((kvp (assq (car path) nested-lst)))
(cond
((not kvp) ; new branch. append to end of parent branch
(list-cdr-set! nested-lst
(1- (length nested-lst))
(path->nested-alist path newval)))
((null? (cdr path)) ; existing branch, last path. replace pair's cdr
(set-cdr! kvp newval))
(else ; existing branch. traverse into next layer.
(loop (cdr kvp) (cdr path)))))))))
(define (nested-alist-get lst path)
(let loop ((nested-lst lst) (path path))
(cond
((null? path)
nested-lst)
((null? nested-lst)
(throw "invalid state. most likely the initial list is empty."))
((and (pair? (car path)) (number? (caar path)))
(cond
((>= (caar path) (vector-length nested-lst))
(throw (format #f "invalid path vector index ~s too high"
(caar path))))
(else
(loop (vector-ref nested-lst (caar path))
(cdr path)))))
(else
(let ((kvp (assq (car path) nested-lst)))
(cond
((not kvp)
(throw (format #f "invalid path: ~s" path)))
(else
(loop (cdr kvp) (cdr path)))))))))
;; helper for setting data - guile-json expects vectors to be
;; transformed into JSON arrays; convert list to vector. if not list,
;; leave alone.
(define (list-to-vec v)
(if (list? v)
(list->vector v)
v))
;; The html-chart specifies options and data to create a chart. The
;; old chart toolkits guppi and jqplot seemed to require restrictive
;; specific formats for the options and data, and modern toolkit
;; chartjs is more permissive. Nonetheless some of the restrictions
;; remain.
;; At minimum the html-chart will require setting the following
;; fields:
;; type - one of 'bar 'line 'pie
;; width - pair
;; height - pair
(define <html-chart>
(make-record-type "<html-chart>"
'(width
height
chart-options
currency-iso
currency-symbol
custom-x-axis-ticks?
custom-y-axis-ticks?)))
(define gnc:html-chart?
(record-predicate <html-chart>))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; <html-chart> class
;; generate the <object> form for an html chart.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define gnc:make-html-chart-internal
(record-constructor <html-chart>))
(define (gnc:make-html-chart)
(gnc:make-html-chart-internal
'(percent . 100) ;;width
'(percent . 100) ;;height
(list ;;chartjs options object
(cons 'type 'bar)
(cons 'data (list
(cons 'labels #())
(cons 'datasets #())))
(cons 'options (list
(cons 'maintainAspectRatio #f)
(cons 'chartArea (list
(cons 'backgroundColor "#fffdf6")))
(cons 'legend (list
(cons 'position 'right)
(cons 'reverse #f)
(cons 'labels (list
(cons 'fontColor 'black)))))
(cons 'elements (list
(cons 'line (list
(cons 'tension 0)))
(cons 'point (list
(cons 'pointStyle #f)))))
(cons 'tooltips (list
(cons 'callbacks (list
(cons 'label #f)))))
(cons 'scales (list
(cons 'xAxes (vector
(list
(cons 'display #t)
(cons 'type 'category)
(cons 'distribution 'series)
(cons 'offset #t)
(cons 'gridLines (list
(cons 'display #t)
(cons 'lineWidth 1.5)))
(cons 'scaleLabel (list
(cons 'display #t)
(cons 'labelString "")))
(cons 'ticks (list
(cons 'fontSize 12)
(cons 'maxRotation 30))))
;; the following another xAxis at the top
'((position . top)
(ticks . ((display . #f)))
(gridLines . ((display . #f)
(drawTicks . #f))))
))
(cons 'yAxes (vector
(list
(cons 'stacked #f)
(cons 'display #t)
(cons 'gridLines (list
(cons 'display #t)
(cons 'lineWidth 1.5)))
(cons 'scaleLabel (list
(cons 'display 1.5)
(cons 'labelString "")))
(cons 'ticks (list
(cons 'fontSize 10)
(cons 'beginAtZero #f))))
;; the following another yAxis on the right
'((position . right)
(ticks . ((display . #f)))
(gridLines . ((display . #f)
(drawTicks . #f))))
))))
(cons 'title (list
(cons 'display #t)
(cons 'fontSize 16)
(cons 'fontStyle "")
(cons 'text ""))))))
"XXX" ;currency-iso
"\u00A4" ;currency-symbol
#t ;custom x-axis ticks?
#t ;custom y-axis ticks?
))
(define gnc:html-chart-width
(record-accessor <html-chart> 'width))
(define gnc:html-chart-set-width!
(record-modifier <html-chart> 'width))
(define gnc:html-chart-height
(record-accessor <html-chart> 'height))
(define gnc:html-chart-set-height!
(record-modifier <html-chart> 'height))
(define (gnc:html-chart-type chart)
(gnc:html-chart-get chart '(type)))
(define (gnc:html-chart-set-type! chart type)
(gnc:html-chart-set! chart '(type) type))
(define (gnc:html-chart-title chart)
(gnc:html-chart-get chart '(options title text)))
(define-public (gnc:html-chart-set-title! chart title)
(gnc:html-chart-set! chart '(options title text) title))
(define-public (gnc:html-chart-set-data-labels! chart labels)
(gnc:html-chart-set! chart '(data labels) labels))
(define-public (gnc:html-chart-set-axes-display! chart display?)
(gnc:html-chart-set! chart '(options scales xAxes (0) display) display?)
(gnc:html-chart-set! chart '(options scales yAxes (0) display) display?))
(export gnc:html-chart-add-data-series!)
;; e.g.:
;; (gnc:html-chart-add-data-series! chart "label" list-of-numbers color
;; 'fill #t
;; 'urls "gnc-report:id=13#")
;;
;; chart - html-chart object
;; "label" - data series label eg. "Income"
;; data - a list-of-numbers specifying series data
;; color - a string specifying series colour
;;
;; other keys may be specified, see ChartJS documentation
;; 'fill - fill bar, or fill under line?
;; 'borderWidth - line width
;; 'urls - either a string (same url for whole data series)
;; or a list-of-string (individual url for each data point)
;; see javascript onClick event handler how they are decoded
(define* (gnc:html-chart-add-data-series! chart label data color . rest)
(let loop ((rest rest)
(newseries (list
(cons 'data (list-to-vec data))
(cons 'label label)
(cons 'backgroundColor (list-to-vec color))
(cons 'borderColor (list-to-vec color)))))
(if (null? rest)
(gnc:html-chart-set!
chart '(data datasets)
(list->vector
(append (vector->list
(or (gnc:html-chart-get chart '(data datasets))
#()))
(list newseries))))
(loop (cddr rest)
(assq-set! newseries
(car rest)
(list-to-vec (cadr rest)))))))
(define-public (gnc:html-chart-clear-data-series! chart)
(gnc:html-chart-set! chart '(data datasets) #()))
(define-public (gnc:html-chart-set-x-axis-label! chart label)
(gnc:html-chart-set! chart '(options scales xAxes (0) scaleLabel labelString) label))
(define-public (gnc:html-chart-set-stacking?! chart stack?)
(gnc:html-chart-set! chart '(options scales xAxes (0) stacked) stack?)
(gnc:html-chart-set! chart '(options scales yAxes (0) stacked) stack?))
(define-public (gnc:html-chart-set-grid?! chart grid?)
(gnc:html-chart-set! chart '(options scales xAxes (0) gridLines display) grid?)
(gnc:html-chart-set! chart '(options scales yAxes (0) gridLines display) grid?))
(define-public (gnc:html-chart-set-y-axis-label! chart label)
(gnc:html-chart-set! chart '(options scales yAxes (0) scaleLabel labelString) label))
(define gnc:html-chart-get-options-internal
(record-accessor <html-chart> 'chart-options))
(define gnc:html-chart-set-options-internal!
(record-modifier <html-chart> 'chart-options))
(define (gnc:html-chart-get chart path)
(let ((options (gnc:html-chart-get-options-internal chart)))
(nested-alist-get options path)))
(define (gnc:html-chart-set! chart path val)
(let ((options (gnc:html-chart-get-options-internal chart))
(val-vec (list-to-vec val)))
(nested-alist-set! options path val-vec)
(gnc:html-chart-set-options-internal! chart options)))
(define gnc:html-chart-currency-iso
(record-accessor <html-chart> 'currency-iso))
(define gnc:html-chart-set-currency-iso!
(record-modifier <html-chart> 'currency-iso))
(define gnc:html-chart-currency-symbol
(record-accessor <html-chart> 'currency-symbol))
(define gnc:html-chart-set-currency-symbol!
(record-modifier <html-chart> 'currency-symbol))
(define gnc:html-chart-custom-x-axis-ticks?
(record-accessor <html-chart> 'custom-x-axis-ticks?))
(define-public gnc:html-chart-set-custom-x-axis-ticks?!
(record-modifier <html-chart> 'custom-x-axis-ticks?))
(define gnc:html-chart-custom-y-axis-ticks?
(record-accessor <html-chart> 'custom-y-axis-ticks?))
(define-public gnc:html-chart-set-custom-y-axis-ticks?!
(record-modifier <html-chart> 'custom-y-axis-ticks?))
(define JS-Number-to-String "
// The following snippet from MDN
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString
var toLocaleStringSupportsOptions = (typeof Intl == 'object' && Intl && typeof Intl.NumberFormat == 'function');
// format a number e.g. 2.5 into monetary e.g. \"$2.50\"
function numformat(amount) {
if (toLocaleStringSupportsOptions) {
return amount.toLocaleString(undefined, {style:'currency', currency:curriso});
} else {
return currsym + amount.toLocaleString();
}
}
")
(define JS-setup "
function tooltipLabel(tooltipItem,data) {
var datasetLabel = data.datasets[tooltipItem.datasetIndex].label || 'Other';
var label = data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index];
switch (typeof(label)) {
case 'number':
return datasetLabel + ': ' + numformat(label);
default:
return '';
}
}
function tooltipTitle(array,data) {
return chartjsoptions.data.labels[array[0].index]; }
// draw the background color
Chart.pluginService.register({
beforeDraw: function (chart, easing) {
if (chart.config.options.chartArea && chart.config.options.chartArea.backgroundColor) {
var ctx = chart.chart.ctx;
var chartArea = chart.chartArea;
ctx.save();
ctx.fillStyle = chart.config.options.chartArea.backgroundColor;
ctx.fillRect(chartArea.left, chartArea.top, chartArea.right - chartArea.left, chartArea.bottom - chartArea.top);
ctx.restore();
}
}
})
document.getElementById(chartid).onclick = function(evt) {
var activepoints = myChart.getElementAtEvent(evt);
var anchor = document.getElementById(jumpid);
switch (activepoints.length) {
case 0:
anchor.href = '';
anchor.textContent = '';
anchor.style = 'display: none';
break;
default:
var index = activepoints[0]['_index'];
var datasetIndex = activepoints[0]['_datasetIndex'];
var datasetURLs = myChart.data.datasets[datasetIndex].urls;
// console.log('index=',index,'datasetIndex=',datasetIndex);
anchor.style = 'position:absolute; top:' + (evt.clientY - 30) + 'px; left:' + (evt.clientX - 20) + 'px; display: block; padding: 5px; border-radius: 5px; background: #4E9CAF; text-align:center; color:white; ';
switch (typeof(datasetURLs)) {
case 'string':
anchor.href = datasetURLs;
anchor.textContent = loadstring;
break;
case 'object':
anchor.href = datasetURLs[index];
anchor.textContent = loadstring;
break;
default:
anchor.href = '';
anchor.textContent = '';
anchor.style = 'display: none';
}
}
}\n\n")
(define (get-options-string chart)
(scm->json-string
(gnc:html-chart-get-options-internal chart)
#:pretty #t))
(define (gnc:html-chart-render chart doc)
(define (size->str size)
(string-append
(number->string (cdr size))
(case (car size)
((pixels) "px")
((percent) "%"))))
(let* ((retval '())
(push (lambda (l) (set! retval (cons l retval))))
;; Use a unique chart-id for each chart. This prevents charts
;; clashing on multi-column reports
(id (random 99999999)))
(push (format #f "<script language='javascript' type='text/javascript' src='file://~a'></script>\n"
(gnc-path-find-localized-html-file "chartjs/Chart.bundle.min.js")))
(push (format #f "<div style='width:~a;height:~a;'>\n"
(size->str (gnc:html-chart-width chart))
(size->str (gnc:html-chart-height chart))))
(push (format #f "<a id='jump-~a' href='' style='display:none'></a>\n" id))
(push (format #f "<canvas id='chart-~a'></canvas>\n" id))
(push "</div>\n")
(push (format #f "<script id='script-~a'>\n" id))
(push (format #f "var curriso = ~s;\n" (gnc:html-chart-currency-iso chart)))
(push (format #f "var currsym = ~s;\n" (gnc:html-chart-currency-symbol chart)))
(push (format #f "var chartid = 'chart-~a';\n" id))
(push (format #f "var jumpid = 'jump-~a';\n" id))
(push (format #f "var loadstring = ~s;\n" (_ "Load")))
(push (format #f "var chartjsoptions = ~a;\n\n"
(get-options-string chart)))
(push JS-Number-to-String)
(when (gnc:html-chart-custom-y-axis-ticks? chart)
(push "chartjsoptions.options.scales.yAxes[0].ticks.callback = yAxisDisplay;\n")
(push "function yAxisDisplay(value,index,values) { return numformat(value); };\n"))
(when (gnc:html-chart-custom-x-axis-ticks? chart)
(push "chartjsoptions.options.scales.xAxes[0].ticks.callback = xAxisDisplay;\n")
(push "function xAxisDisplay(value,index,values) { return chartjsoptions.data.labels[index]; };\n"))
(push "chartjsoptions.options.tooltips.callbacks.label = tooltipLabel;\n")
(push "chartjsoptions.options.tooltips.callbacks.title = tooltipTitle;\n")
(push "Chart.defaults.global.defaultFontFamily = \"'Trebuchet MS', Arial, Helvetica, sans-serif\";\n")
(push JS-setup)
(push "var myChart = new Chart(chartid, chartjsoptions);\n")
(push "</script>")
retval))

View File

@ -369,6 +369,9 @@
((gnc:html-anytag? obj)
(gnc:make-html-object-internal gnc:html-anytag-render obj))
((gnc:html-chart? obj)
(gnc:make-html-object-internal gnc:html-chart-render obj))
((gnc:html-table-cell? obj)
(gnc:make-html-object-internal gnc:html-table-cell-render obj))

View File

@ -427,6 +427,7 @@
(export gnc:html-linechart-render linechart)
(export gnc:html-linechart-set-line-width!)
(export gnc:html-linechart-line-width)
;; html-style-info.scm
(export <html-markup-style-info>)
@ -558,6 +559,27 @@
(export gnc-commodity-table)
(export gnc:uniform-commodity?)
;; html-chart.scm
(export gnc:html-chart?)
(export gnc:make-html-chart)
(export gnc:html-chart-data)
(export gnc:html-chart-set-data!)
(export gnc:html-chart-width)
(export gnc:html-chart-set-width!)
(export gnc:html-chart-height)
(export gnc:html-chart-set-height!)
(export gnc:html-chart-type)
(export gnc:html-chart-set-type!)
(export gnc:html-chart-title)
(export gnc:html-chart-get)
(export gnc:html-chart-set!)
(export gnc:html-chart-currency-iso)
(export gnc:html-chart-set-currency-iso!)
(export gnc:html-chart-currency-symbol)
(export gnc:html-chart-set-currency-symbol!)
(export gnc:html-chart-render)
;; html-table.scm
(export <html-table>)
@ -762,6 +784,7 @@
(load-from-path "commodity-utilities")
(load-from-path "html-chart")
(load-from-path "html-barchart")
(load-from-path "html-document")
(load-from-path "html-piechart")

View File

@ -19,6 +19,7 @@ set (scm_test_report_system_with_srfi64_SOURCES
test-report-utilities.scm
test-html-utilities-srfi64.scm
test-html-fonts.scm
test-html-chart.scm
test-report-html.scm
test-report-system.scm
)

View File

@ -0,0 +1,111 @@
(use-modules (gnucash gnc-module))
(gnc:module-begin-syntax (gnc:module-load "gnucash/app-utils" 0))
(gnc:module-begin-syntax (gnc:module-load "gnucash/report/report-system" 0))
(use-modules (srfi srfi-64))
(use-modules (tests srfi64-extras))
(use-modules (tests test-engine-extras))
(use-modules (tests test-report-system-extras))
(use-modules (gnucash report report-system))
(define (run-test)
(test-runner-factory gnc:test-runner)
(test-begin "test-html-chart.scm")
(test-html-chart)
(test-end "test-html-chart.scm"))
(define (test-html-chart)
(let ((chart (gnc:make-html-chart)))
(gnc:html-chart-add-data-series! chart "label" '(2 3 4) "red")
;; general setters and getters
(gnc:html-chart-set! chart '(data datasets (0) data) #(1 2 3))
(test-equal "data setter & getter"
#(1 2 3)
(gnc:html-chart-get chart '(data datasets (0) data)))
(gnc:html-chart-set! chart '(type) 'scatter)
(test-equal "type setter & getter"
'scatter
(gnc:html-chart-get chart '(type)))
;; options setters and getters
;;
;; options are stored in a nested list of pairs, in which the car
;; is a symbol, the cdr is either a value (bool/string/number)
;; another list of pairs, or another simple list
;; (list (cons 'maintainAspectRatio #f)
;; (cons 'chartArea (list (cons 'backgroundColor "white")))
;; (cons 'scales (list (cons 'xAxes (list
;; (list (cons 'display #t)
;; (cons 'gridlines (list (cons 'display #t)
;; (cons 'lineWidth 1.5)))
;; (cons 'ticks (list (cons 'fontSize 12)))))))))
;; traversal is accomplished as a list of symbols or numbers
;; e.g. '(maintainAspectRatio), '(chartArea backgroundColor),
;; '(scales xAxes (0) display). NOTE: xAxes specifies a number to
;; identify the kth element in the list. this is required as per
;; chartjs specification.
;;
;; syntax is: (gnc:html-chart-set! chart path newval) or
;; (gnc:html-chart-get chart path)
(gnc:html-chart-set! chart '(options maintainAspectRatio) 'abc)
(test-equal "root option setter & getter"
'abc
(gnc:html-chart-get chart '(options maintainAspectRatio)))
(gnc:html-chart-set! chart '(options legend position) 'de)
(test-equal "1st level option setter & getter"
'de
(gnc:html-chart-get chart '(options legend position)))
(test-error
"1st level option fails - cannot traverse through existing path"
'invalid-path
(gnc:html-chart-set! chart '(options legend position invalid) 'de))
(test-equal "deep nested new path - inexistent"
#f
(gnc:html-chart-get chart '(create new nested path)))
(gnc:html-chart-set! chart '(create new nested path) 'newpath)
(test-equal "created deep nested new path"
'newpath
(gnc:html-chart-get chart '(create new nested path)))
(gnc:html-chart-set! chart '(create list-kth (4) nested path) 'k4th)
(test-equal "deep nested new path - created 4th list item"
'k4th
(gnc:html-chart-get chart '(create list-kth (4) nested path)))
(gnc:html-chart-set! chart '(create list-kth (1) nested path) 'k1th)
(test-equal "deep nested new path - created 1th list item"
'k1th
(gnc:html-chart-get chart '(create list-kth (1) nested path)))
(gnc:html-chart-set! chart '(create list-kth (0) nested path) 'k0th)
(test-equal "deep nested new path - created 0th list item"
'k0th
(gnc:html-chart-get chart '(create list-kth (0) nested path)))
(test-equal "deep nested new path - 4th list item intact"
'k4th
(gnc:html-chart-get chart '(create list-kth (4) nested path)))
(gnc:html-chart-set! chart '(create list-kth (3)) 'three)
(test-equal "deep nested new path - 3th list item is the last path"
'three
(gnc:html-chart-get chart '(create list-kth (3))))
(test-error
"deep nested new path - cannot set 6th index"
'error
(gnc:html-chart-set! chart '(create list-kth (6) nested path) 'k4th))
))

View File

@ -480,6 +480,7 @@ gnucash/report/report-system/gnc-report.c
gnucash/report/report-system/html-acct-table.scm
gnucash/report/report-system/html-anytag.scm
gnucash/report/report-system/html-barchart.scm
gnucash/report/report-system/html-chart.scm
gnucash/report/report-system/html-document.scm
gnucash/report/report-system/html-fonts.scm
gnucash/report/report-system/html-linechart.scm