Project

General

Profile

Improvements #12495 » WChartJSLibrary.C

Mark Travis, 07/09/2026 07:46 PM

 
//
// Created by mark on 5/30/25.
//

#include <any>
#include <string_view>
#include <utility>
#include <boost/proto/transform/env.hpp>
#include <ReportsAndCharts//WChartJSLibrary.h>
#include <Wt/EscapeOStream.h>
#include <Wt/WAbstractItemModel.h>
#include <Wt/WApplication.h>
#include <Wt/WFlags.h>
#include <Wt/WPainter.h>
#include <Wt/WWidget.h>
#include <Wt/Chart/WAbstractChartModel.h>
#include <Wt/Chart/WStandardChartProxyModel.h>
#include <Wt/Json/Serializer.h>

constexpr std::string_view WChartJSLibrary_js = R"wtjs(
window.WChartJSLibrary = window.WChartJSLibrary || function(APP, el, chart_config_str) {
const self = this;
el.chartJSObj = this;
this.chart = null;
this.widget = el;

this.canvas = document.createElement('canvas');
this.canvas.style.position = 'absolute';
this.canvas.style.top = '0';
this.canvas.style.left = '0';
this.canvas.style.width = '100%';
this.canvas.style.height = '100%';
el.appendChild(this.canvas);

this.handleResize = function() {
if (self.chart && self.canvas && self.canvas.parentNode) {
self.chart.resize();
}
};

this.addNonModelData = function(label_str, newData_str) {
const label = JSON.parse(label_str);
const newData = JSON.parse(newData_str);
self.chart.data.labels.push(label);
self.chart.data.datasets.forEach((dataset) => {
dataset.data.push(newData);
});
self.chart.update();
}

this.resetSpecificDataSeries = function(seriesNumber) {
var index = parseInt(seriesNumber, 10);
if (self.chart && self.chart.data && self.chart.data.datasets && self.chart.data.datasets[index]) {
self.chart.data.datasets[index].data = [];
self.chart.update();
} else {
console.warn("ChartJS tried to reset dataset at index " + index + ", but it is undefined.");
}
}

this.removeSpecificDataSeries = function(seriesNumber) {
self.chart.data.datasets.splice(seriesNumber, 1);
self.chart.update();
}

this.replaceSpecificDataSeries = function(seriesNumber, data_str) {
const newData = JSON.parse(data_str);
self.chart.data.datasets.splice(seriesNumber, 1);
self.chart.data.datasets.push(newData);
self.chart.update();
}

this.removeAllData = function() {
self.chart.data.labels.pop();
self.chart.data.datasets.forEach((dataset) => {
dataset.data.pop();
});
self.chart.update();
}

this.addModelData = function(data_str) {
const newData = JSON.parse(data_str);
self.chart.data.datasets.push(newData);
self.chart.update();
}

this.appendExistingDataSeries = function(seriesNumber, data_str) {
self.chart.options.animations.x = false;
const newData = JSON.parse(data_str);
const data = newData.data;
const targetDataset = self.chart.data.datasets[seriesNumber];
targetDataset.data = targetDataset.data.concat(data);
self.chart.data.labels = targetDataset.data.map((_, index) => index + 1);
self.chart.update();
}

this.updateChartTitle = function(title_str) {
self.chart.options.plugins.title.text = title_str;
self.chart.update();
}

this.updateConfigAsNewObject = function(config_str) {
const options = JSON.parse(config_str);
self.chart.options = options;
self.chart.update();
}

this.destroy = function() {
if (self.chart) self.chart.destroy();
}

this.init = function(chart_config_str) {
const chart_config = JSON.parse(chart_config_str);
const type = chart_config.type;
const data = chart_config.data || {};

if (data.labels === "datacount" && data.datasets && data.datasets.length > 0) {
data.labels = data.datasets[0].data.map((_, index) => index + 1);
}

const plugins = chart_config.plugins || {};
const options = chart_config.options || {};

options.onClick = function(event, activeElements, chart) {
if (activeElements && activeElements.length > 0) {
const firstPoint = activeElements[0];
Wt.emit(self.widget, 'chartClicked', firstPoint.datasetIndex, firstPoint.index);
}
};

if (options && options.scales && options.scales.x && options.scales.x.ticks && options.scales.x.ticks.callback === "toFixed") {
options.scales.x.ticks.callback = function(value, index, values) { return value.toFixed(0); };
}

if (options && options.plugins && options.plugins.tooltip && options.plugins.tooltip.callback === "percentage") {
options.plugins.tooltip.callbacks = {
label: function(context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += (context.parsed.y * 100).toFixed(1) + '%';
}
return label;
}
};
}

self.chart = new Chart(self.canvas, {
type,
data,
plugins,
options
});
};

if (chart_config_str) this.init(chart_config_str);
};
)wtjs";

WChartJSLibrary::WChartJSLibrary(const ChartJSType chartType) : chartType_(chartType),
resized_(false),
jsDefined_(false),
chartClicked_(this, "chartClicked")
{
wApp->enableUpdates(true);
WWebWidget::setPositionScheme(Wt::PositionScheme::Relative);

setWidth(Wt::WLength(100, Wt::LengthUnit::Percentage));
// Must hard code the height or ChartJS resizing function will go into an infinite
// resizing loop with CSS Flexbox
setHeight(Wt::WLength(300, Wt::LengthUnit::Pixel));
chartTypeName_ = getChartJSType(chartType);
}

std::string WChartJSLibrary::getChartJSType(ChartJSType type) {
switch (chartType_) {
case ChartJSType::BAR: return "bar";
case ChartJSType::BUBBLE: return "bubble";
case ChartJSType::DOUGHNUT: return "doughnut";
case ChartJSType::PIE: return "pie";
case ChartJSType::LINE: return "line";
case ChartJSType::POLAR: return "polarArea";
case ChartJSType::RADAR: return "radar";
case ChartJSType::SCATTER: return "scatter";
}

return "line";
}

void WChartJSLibrary::updateChartTitleOnly(const Wt::WString& title) {
set(titleText_, title);
if (fullyRendered_) {
Wt::WStringStream jsStream; // Local stream
{ // Scope block for EscapeOStream
Wt::EscapeOStream es(jsStream);
es << "var o=" << jsRef() << ";if(o && o.chartJSObj){o.chartJSObj.updateChartTitle('";
es.pushEscape(Wt::EscapeOStream::JsStringLiteralSQuote);
es << titleText_.toUTF8();
es.popEscape();
es << "');};";
} // Forces flush

doJavaScript(jsStream.str());
}
}

void WChartJSLibrary::setModel(const std::shared_ptr<Wt::Chart::WAbstractChartModel>& model) {
if (model_ != nullptr) {
/* disconnect slots from previous model */
for (auto& modelConnection : modelConnections_)
modelConnection.disconnect();
modelConnections_.clear();
}
model_ = model;
modelConnections_.push_back(model_->changed().connect
(this, &WChartJSLibrary::modelChanged));

modelChanged();
}

void WChartJSLibrary::setModel(const std::shared_ptr<Wt::WAbstractItemModel>& model) {
standardModel_ = model;
setModel(std::shared_ptr<Wt::Chart::WAbstractChartModel>(
std::make_shared<Wt::Chart::WStandardChartProxyModel>(model)));
}

std::shared_ptr<Wt::WAbstractItemModel> WChartJSLibrary::itemModel() const {
const auto* proxy
= dynamic_cast<Wt::Chart::WStandardChartProxyModel*>(model_.get());
if (proxy)
return proxy->sourceModel();
else
return nullptr;
}

void WChartJSLibrary::modelChanged() {
// not sure what should be here. Will figure it out later.
// modelColumnsLoaded_.clear();
}

void WChartJSLibrary::updateModel() {
if (!modelColumnsLoaded_.empty()) {
// add test for multiple series. Right now, assume only one
Wt::Json::Object dataSeries;
Wt::Json::Array series;
int modelCount = static_cast<int>(modelColumnsLoaded_.size());
for (unsigned i = 0; i < modelCount; ++i) {
for (int j = 0; j < model_->rowCount(); ++j) {
series.push_back(model_->data(j, modelColumnsLoaded_[i].modelColumnNumber));
}

if (modelColumnsLoaded_[i].countForLabels) {
countForLabels_ = true;
dataSeries["labels"] = Wt::Json::Value("datacount");
} else {
countForLabels_ = false;
dataLabels_.clear();
int numRows = standardModel_->rowCount();
for (int j = 0; j < numRows; ++j) {
auto headerData = std::any_cast<std::string>(
standardModel_->headerData(j, Wt::Orientation::Vertical, Wt::ItemDataRole::Display));
dataLabels_.push_back(Wt::Json::Value(headerData));
}
dataSeries["labels"] = Wt::Json::Value(dataLabels_);
}

dataSeries["data"] = series;
std::string output = Wt::Json::serialize(dataSeries);

Wt::WStringStream jsStream; // Local stream
{ // Scope block for EscapeOStream
Wt::EscapeOStream es(jsStream);
es << "var o=" << jsRef() << ";if(o && o.chartJSObj){o.chartJSObj.replaceSpecificDataSeries('" << i << "','";
es.pushEscape(Wt::EscapeOStream::JsStringLiteralSQuote);
es << output;
es.popEscape();
es << "');};";
} // Forces flush

doJavaScript(jsStream.str());

// if there are multiple series, this javascript function will delete this particular series
// and replace it with the newly created series by pushing it onto the end of datasets. To
// remain in sync, the same needs to be done with the modelColumnsLoaded_.
ModelColumnInfo modelColumnInfo = modelColumnsLoaded_[i];
modelColumnsLoaded_.erase(modelColumnsLoaded_.begin() + i);
modelColumnsLoaded_.push_back(modelColumnInfo);

}
}
}

std::pair<int, int> WChartJSLibrary::getModelCoordinates(int datasetIndex, int dataIndex) const {
// 1. Bounds checking for safety against rogue JS events
if (datasetIndex < 0 || datasetIndex >= static_cast<int>(modelColumnsLoaded_.size())) {
return {-1, -1};
}

// 2. The dataIndex from Chart.js maps 1:1 to the rows you iterated over in addSeries
int modelRow = dataIndex;

// 3. Look up the exact C++ model column from your tracking struct
int modelColumn = modelColumnsLoaded_[datasetIndex].modelColumnNumber;

return {modelRow, modelColumn};
}

// addSeries needs a modelColumn and a fully populated Wt::Json::Object called ChartJSDataSeries
void WChartJSLibrary::addSeries(int modelColumn, bool countForLabels, Wt::Json::Object dataSeriesOptions,
bool dependantIndependantSeries) {
// if series_[modelColumn] exists, throw an error otherwise, set value.
int labelCol = dependantIndependantSeries ? 1 : modelColumn;
//concatenate the data series title and the actual data and add it to the series_ std::map
Wt::Json::Object dataSeries = std::move(dataSeriesOptions);
Wt::Json::Array series;
// If the Chart type is bubble, the data should be constructed as [{x: 0, y: 0, r: 0}]
if ((chartType_ != ChartJSType::BUBBLE) && !dependantIndependantSeries) {
for (int i = 0; i < model_->rowCount(); ++i) {
series.push_back(model_->data(i, modelColumn));
}
}

if (dependantIndependantSeries) {
for (int i = 0; i < model_->rowCount(); ++i) {
series.push_back(model_->data(i, 1));
}
}

// set the labels for contiguous data points when there are no row headers set
countForLabels_ = countForLabels;
// and the data is numeric
dataSeries["data"] = series;

auto headerData = Wt::asString(standardModel_->headerData(labelCol, Wt::Orientation::Horizontal, Wt::ItemDataRole::Display)).toUTF8();
dataSeries["label"] = Wt::Json::Value(headerData);

if (countForLabels_) {
dataSeries["label"] = model_->headerData(modelColumn);
} else {
dataLabels_.clear();
int numRows = standardModel_->rowCount();
for (int i = 0; i < numRows; ++i) {
// Extract the raw, unformatted data from the Edit role
auto editData = standardModel_->data(i, 0, Wt::ItemDataRole::Edit);

// Safely unpack the std::any container based on its true C++ type
if (editData.type() == typeid(double)) {
dataLabels_.push_back(Wt::Json::Value(std::any_cast<double>(editData)));
} else if (editData.type() == typeid(int)) {
dataLabels_.push_back(Wt::Json::Value(std::any_cast<int>(editData)));
} else if (editData.type() == typeid(long long)) {
dataLabels_.push_back(Wt::Json::Value(static_cast<double>(std::any_cast<long long>(editData))));
} else {
// Fallback for categorical strings and dates
auto labelStr = Wt::asString(editData).toUTF8();
dataLabels_.push_back(Wt::Json::Value(labelStr));
}
}
}

Wt::Json::Object additionalSeries;
if (fullyRendered_) additionalSeries = Wt::Json::Value(dataSeries);
ModelColumnInfo colInfo;
colInfo.countForLabels = countForLabels_;
colInfo.dependantIndependantSeries = dependantIndependantSeries;
colInfo.modelColumnNumber = modelColumn;
colInfo.lastDataCount = model_->rowCount();
modelColumnsLoaded_.push_back(colInfo);
series_.push_back(dataSeries);
assert(modelColumnsLoaded_.size() == series_.size());
// If the chart is already rendered, we need to add the net change to the
// chart. Otherwise, the prior data will get picked up on the full render.

if (fullyRendered_) {
std::string output = Wt::Json::serialize(additionalSeries);
Wt::WStringStream jsStream; // Local stream

{ // Scope block for EscapeOStream
Wt::EscapeOStream es(jsStream);
es << "var o=" << jsRef() << ";if(o && o.chartJSObj){o.chartJSObj.addModelData('";
es.pushEscape(Wt::EscapeOStream::JsStringLiteralSQuote);
es << output;
es.popEscape();
es << "');};";
} // Forces flush

doJavaScript(jsStream.str()); // Execute directly instead of relying on render()
}
scheduleRender();
}

void WChartJSLibrary::addRawSeries(const Wt::Json::Object& rawSeries) {
series_.push_back(rawSeries);
if (fullyRendered_) {
std::string output = Wt::Json::serialize(rawSeries);
Wt::WStringStream jsStream;
{
Wt::EscapeOStream es(jsStream);
es << "var o=" << jsRef() << ";if(o && o.chartJSObj){o.chartJSObj.addModelData('";
es.pushEscape(Wt::EscapeOStream::JsStringLiteralSQuote);
es << output;
es.popEscape();
es << "');};";
}
doJavaScript(jsStream.str());
}
scheduleRender();
}

void WChartJSLibrary::updateExistingSeries() {
if (!modelColumnsLoaded_.empty()) {
// add test for multiple series. Right now, assume only one
Wt::Json::Object dataSeries;
Wt::Json::Array series;
int modelCount = static_cast<int>(modelColumnsLoaded_.size());
for (int i = 0; i < modelCount; ++i) {
int netChangeRows = (int)model_->rowCount() - modelColumnsLoaded_[i].lastDataCount;
if (netChangeRows < 0) {
modelColumnsLoaded_[i].lastDataCount = 0;
resetSeries(modelColumnsLoaded_[i].modelColumnNumber);
netChangeRows = (int)model_->rowCount();
}
if (netChangeRows == 0) continue;
int recordsAdded = 0;
for (int j = modelColumnsLoaded_[i].lastDataCount; j < model_->rowCount(); j++) {
series.push_back(model_->data(j, modelColumnsLoaded_[i].modelColumnNumber));
recordsAdded++;
}
modelColumnsLoaded_[i].lastDataCount += recordsAdded;

if (modelColumnsLoaded_[i].countForLabels) {
countForLabels_ = true;
dataSeries["labels"] = Wt::Json::Value("datacount");
} else {
countForLabels_ = false;
dataLabels_.clear();
int numRows = standardModel_->rowCount();
for (int i = 0; i < numRows; ++i) {
auto labelStr = Wt::asString(standardModel_->data(i, 0, Wt::ItemDataRole::Display)).toUTF8();
dataLabels_.push_back(Wt::Json::Value(labelStr));
}
dataSeries["labels"] = Wt::Json::Value(dataLabels_);
}

dataSeries["data"] = series;
std::string output = Wt::Json::serialize(dataSeries);

Wt::WStringStream jsStream; // Local stream
{ // Scope block for EscapeOStream
Wt::EscapeOStream es(jsStream);
es << "var o=" << jsRef() << ";if(o && o.chartJSObj){o.chartJSObj.appendExistingDataSeries(" << i << ",'";
es.pushEscape(Wt::EscapeOStream::JsStringLiteralSQuote);
es << output;
es.popEscape();
es << "');};";
} // Forces flush

doJavaScript(jsStream.str());
}
}
}

void WChartJSLibrary::removeSeries(int modelColumn) {
Wt::WStringStream jsStream; // Local stream
{
Wt::EscapeOStream es(jsStream);
if (!modelColumnsLoaded_.empty()) {

// CRITICAL: Iterate backwards so erasing items don't corrupt the loop index!
for (int j = modelColumnsLoaded_.size() - 1; j >= 0; --j) {
if (modelColumnsLoaded_[j].modelColumnNumber == modelColumn) {

// 1. Tell JS to completely destroy the dataset
es << "var o=" << jsRef() << ";if(o && o.chartJSObj){"
"if(o.chartJSObj.chart.data.datasets[" << j << "]) {"
" o.chartJSObj.chart.data.datasets.splice(" << j << ", 1);"
" o.chartJSObj.chart.update();"
"}"
"};";

// 2. Remove it from the C++ tracker so it stops stacking up infinitely
series_.erase(series_.begin() + j);
modelColumnsLoaded_.erase(modelColumnsLoaded_.begin() + j);
}
}
}
}
doJavaScript(jsStream.str());
}

void WChartJSLibrary::removeLastRawSeries() {
if (series_.empty()) return;

// Grab the index of the last dataset (which will be our residuals)
int lastIndex = series_.size() - 1;

// Remove from the C++ tracker so it doesn't infinitely stack on redraws
series_.erase(series_.begin() + lastIndex);

// Call your existing JavaScript function to splice it out of ChartJS
Wt::WStringStream jsStream;
{
Wt::EscapeOStream es(jsStream);
es << "var o=" << jsRef() << ";if(o && o.chartJSObj){"
<< "o.chartJSObj.removeSpecificDataSeries(" << lastIndex << ");"
<< "};";
}
doJavaScript(jsStream.str());
}


void WChartJSLibrary::resetSeries(int modelColumn) {
Wt::WStringStream jsStream;
{
Wt::EscapeOStream es(jsStream);
if (!modelColumnsLoaded_.empty()) {
for (int j = 0; j < modelColumnsLoaded_.size(); ++j) {
if (modelColumnsLoaded_[j].modelColumnNumber == modelColumn) {
// FIX: Removed the single quotes around the j variable
es << "var o=" << jsRef() << ";if(o && o.chartJSObj){"
"o.chartJSObj.resetSpecificDataSeries(" << j << ");"
"};";
}
}
}
}
doJavaScript(jsStream.str());
}

void WChartJSLibrary::setXaxisLabels(const std::vector<std::string>& labels) {
dataLabels_.clear();
for (const auto& label : labels) {
dataLabels_.push_back(Wt::Json::Value(label));
}
countForLabels_ = false;
}

void WChartJSLibrary::setOptions(const Wt::Json::Object& chartOptions) {
chartOptions_ = chartOptions;
}

void WChartJSLibrary::defineJavaScript() {
Wt::WApplication* app = Wt::WApplication::instance();

// 1. Ensure external libraries load
app->require("https://cdn.jsdelivr.net/npm/chart.js");
app->require("https://cdn.jsdelivr.net/npm/perfect-scrollbar@1.5.6/dist/perfect-scrollbar.min.js");
app->require("https://cdn.jsdelivr.net/npm/smooth-scrollbar@8.8.4/dist/smooth-scrollbar.min.js");
app->require("https://cdn.jsdelivr.net/npm/hammerjs@2.0.8");
app->require("https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.2.0/dist/chartjs-plugin-zoom.min.js");

std::string output = Wt::Json::serialize(chartjsConfigObj);
Wt::WStringStream jsStream;

// 2. Output the class definition securely into the stream
jsStream << std::string(WChartJSLibrary_js) << "\n";

// 3. Instantiate the chart using the newly defined class
{
Wt::EscapeOStream es(jsStream);
es << "var el = " << jsRef() << ";"
<< "if (el) {"
<< " el.chartJSObj = new window.WChartJSLibrary(Wt, el, '";
es.pushEscape(Wt::EscapeOStream::JsStringLiteralSQuote);
es << output;
es.popEscape();
es << "');"
<< "}";
}

// 4. Force Wt to evaluate everything synchronously in the widget queue
doJavaScript(jsStream.str());

jsDefined_ = true;
scheduleRender();
}

void WChartJSLibrary::render(Wt::WFlags<Wt::RenderFlag> flags) {
if (flags.test(Wt::RenderFlag::Full) || !jsDefined_) {
defineJavaScript();
fullyRendered_ = true;
}

Wt::WContainerWidget::render(flags);
}

void WChartJSLibrary::layoutSizeChanged(int width, int height) {
Wt::WContainerWidget::layoutSizeChanged(width, height);
if (jsDefined_ && fullyRendered_) {
// C++20 formatting for string concatenation
doJavaScript(std::format("var o={}; if(o && o.chartJSObj) o.chartJSObj.handleResize();", jsRef()));
}
}

WChartJSLibrary::~WChartJSLibrary() {
if (jsDefined_) {
// Clean up Chart.js to prevent memory leaks in the browser
Wt::WWebWidget::doJavaScript(std::format("var o={}; if(o && o.chartJSObj) o.chartJSObj.destroy();", jsRef()));
}
}

void WChartJSLibrary::buildAndShowChart() {
chartjsConfigObj["type"] = Wt::Json::Value(chartTypeName_);

if (countForLabels_) {
chartData_["labels"] = Wt::Json::Value("datacount");
countForLabels_ = false;
} else {
chartData_["labels"] = Wt::Json::Value(dataLabels_);
}

chartData_["datasets"] = Wt::Json::Value(series_);
chartjsConfigObj["data"] = Wt::Json::Value(chartData_);

if (!chartOptions_.empty()) {
chartjsConfigObj["options"] = Wt::Json::Value(chartOptions_);
} else {
// FIX: Use an empty Json::Object instead of the string "{},"
// which breaks JSON parsing in JS
chartjsConfigObj["options"] = Wt::Json::Object{};
}

if (!plugins_.empty()) {
chartjsConfigObj["plugins"] = Wt::Json::Value(plugins_);
}

// NEW: If the chart is already on screen, forcefully sync the whole thing
// to pick up any new Date boundaries from the Redraw button.
if (fullyRendered_) {
std::string output = Wt::Json::serialize(chartjsConfigObj);
Wt::WStringStream jsStream;
{
Wt::EscapeOStream es(jsStream);
es << "var o=" << jsRef() << ";if(o && o.chartJSObj){"
<< "o.chartJSObj.destroy();"
<< "o.chartJSObj.init('";
es.pushEscape(Wt::EscapeOStream::JsStringLiteralSQuote);
es << output;
es.popEscape();
es << "');};";
}
doJavaScript(jsStream.str());
}

scheduleRender();
}
(4-4/9)