package org.projectbuendia.client.ui.chart;
import android.content.res.Resources;
import android.util.DisplayMetrics;
import android.webkit.WebChromeClient;
import android.webkit.WebView;
import com.google.common.collect.Lists;
import com.mitchellbosecke.pebble.PebbleEngine;
import org.joda.time.Chronology;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.joda.time.LocalDate;
import org.joda.time.ReadableInstant;
import org.joda.time.chrono.ISOChronology;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.projectbuendia.client.R;
import org.projectbuendia.client.models.AppModel;
import org.projectbuendia.client.models.Chart;
import org.projectbuendia.client.models.ChartItem;
import org.projectbuendia.client.models.ChartSection;
import org.projectbuendia.client.models.Obs;
import org.projectbuendia.client.models.ObsPoint;
import org.projectbuendia.client.models.Order;
import org.projectbuendia.client.utils.Logger;
import org.projectbuendia.client.utils.Utils;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.SortedSet;
import java.util.TreeMap;
import java.util.TreeSet;
/** Renders a patient's chart to HTML displayed in a WebView. */
public class ChartRenderer {
static PebbleEngine sEngine;
private static final Logger LOG = Logger.create();
WebView mView; // view into which the HTML table will be rendered
Resources mResources; // resources used for localizing the rendering
private List<Obs> mLastRenderedObs; // last set of observations rendered
private List<Order> mLastRenderedOrders; // last set of orders rendered
private Chronology chronology = ISOChronology.getInstance(DateTimeZone.getDefault());
private String lastChart = "";
public interface GridJsInterface {
@android.webkit.JavascriptInterface
void onNewOrderPressed();
@android.webkit.JavascriptInterface void onOrderHeadingPressed(String orderUuid);
@android.webkit.JavascriptInterface
void onOrderCellPressed(String orderUuid, long startMillis);
@android.webkit.JavascriptInterface
void onObsDialog(String conceptUuid, String startMillis, String stopMillis);
@android.webkit.JavascriptInterface
void onPageUnload(int scrollX, int scrollY);
}
public ChartRenderer(WebView view, Resources resources) {
mView = view;
mResources = resources;
}
/** Renders a patient's history of observations to an HTML table in the WebView. */
// TODO/cleanup: Have this take the types that getObservations and getLatestObservations return.
public void render(Chart chart, Map<String, Obs> latestObservations,
List<Obs> observations, List<Order> orders,
LocalDate admissionDate, LocalDate firstSymptomsDate,
GridJsInterface controllerInterface) {
if (chart == null) {
mView.loadUrl("file:///android_asset/no_chart.html");
return;
}
if ((observations.equals(mLastRenderedObs) && orders.equals(mLastRenderedOrders))
&& (lastChart.equals(chart.name))){
return; // nothing has changed; no need to render again
}
lastChart = chart.name;
// setDefaultFontSize is supposed to take a size in sp, but in practice
// the fonts don't change size when the user font size preference changes.
// So, we apply the scaling factor explicitly, defining 1 em to be 10 sp.
DisplayMetrics metrics = mResources.getDisplayMetrics();
float defaultFontSize = 10*metrics.scaledDensity/metrics.density;
mView.getSettings().setDefaultFontSize((int) defaultFontSize);
mView.getSettings().setJavaScriptEnabled(true);
mView.addJavascriptInterface(controllerInterface, "controller");
mView.setWebChromeClient(new WebChromeClient());
String html = new GridHtmlGenerator(chart, latestObservations, observations, orders,
admissionDate, firstSymptomsDate).getHtml();
mView.loadDataWithBaseURL("file:///android_asset/", html,
"text/html; charset=utf-8", "utf-8", null);
mView.setWebContentsDebuggingEnabled(true);
mLastRenderedObs = observations;
mLastRenderedOrders = orders;
}
class GridHtmlGenerator {
List<String> mTileConceptUuids;
List<String> mGridConceptUuids;
List<Order> mOrders;
DateTime mNow;
Column mNowColumn;
LocalDate mAdmissionDate;
LocalDate mFirstSymptomsDate;
List<List<Tile>> mTileRows = new ArrayList<>();
List<org.projectbuendia.client.ui.chart.Row> mRows = new ArrayList<>();
Map<String, org.projectbuendia.client.ui.chart.Row> mRowsByUuid = new HashMap<>(); // unordered, keyed by concept UUID
SortedMap<Long, Column> mColumnsByStartMillis = new TreeMap<>(); // ordered by start millis
Set<String> mConceptsToDump = new HashSet<>(); // concepts whose data to dump in JSON
GridHtmlGenerator(Chart chart, Map<String, Obs> latestObservations,
List<Obs> observations, List<Order> orders,
LocalDate admissionDate, LocalDate firstSymptomsDate) {
mAdmissionDate = admissionDate;
mFirstSymptomsDate = firstSymptomsDate;
mOrders = orders;
mNow = DateTime.now();
mNowColumn = getColumnContainingTime(mNow); // ensure there's a column for today
for (ChartSection tileGroup : chart.tileGroups) {
List<Tile> tileRow = new ArrayList<>();
for (ChartItem item : tileGroup.items) {
ObsPoint[] points = new ObsPoint[item.conceptUuids.length];
for (int i = 0; i < points.length; i++) {
Obs obs = latestObservations.get(item.conceptUuids[i]);
if (obs != null) {
points[i] = obs.getObsPoint();
}
}
tileRow.add(new Tile(item, points));
if (!item.script.trim().isEmpty()) {
mConceptsToDump.addAll(Arrays.asList(item.conceptUuids));
}
}
mTileRows.add(tileRow);
}
for (ChartSection section : chart.rowGroups) {
for (ChartItem item : section.items) {
Row row = new Row(item);
mRows.add(row);
mRowsByUuid.put(item.conceptUuids[0], row);
if (!item.script.trim().isEmpty()) {
mConceptsToDump.addAll(Arrays.asList(item.conceptUuids));
}
}
}
addObservations(observations);
addOrders(orders);
insertEmptyColumns();
}
void addObservations(List<Obs> observations) {
for (Obs obs : observations) {
if (obs == null) continue;
Column column = getColumnContainingTime(obs.time);
if (obs.conceptUuid.equals(AppModel.ORDER_EXECUTED_CONCEPT_UUID)) {
Integer count = column.executionCountsByOrderUuid.get(obs.value);
column.executionCountsByOrderUuid.put(
obs.value, count == null ? 1 : count + 1);
} else {
addObs(column, obs);
}
}
}
/** Ensures that columns are shown for any days in which an order is prescribed. */
void addOrders(List<Order> orders) {
for (Order order : orders) {
if (order.stop != null) {
for (DateTime dt = order.start; !dt.isAfter(order.stop.plusDays(1)); dt = dt.plusDays(1)) {
getColumnContainingTime(dt); // creates the column if it doesn't exist
}
}
}
}
/** Returns the column that contains the given instant, creating it if it doesn't exist. */
Column getColumnContainingTime(ReadableInstant instant) {
LocalDate date = new DateTime(instant).toLocalDate(); // a day in the local time zone
DateTime start = date.toDateTimeAtStartOfDay();
long startMillis = start.getMillis();
if (!mColumnsByStartMillis.containsKey(startMillis)) {
int admitDay = Utils.dayNumberSince(mAdmissionDate, date);
String admitDayLabel = (admitDay >= 1) ?
mResources.getString(R.string.day_n, admitDay) : "–";
String dateLabel = date.toString("d MMM");
mColumnsByStartMillis.put(startMillis, new Column(
start, start.plusDays(1), admitDayLabel + "<br>" + dateLabel));
}
return mColumnsByStartMillis.get(startMillis);
}
void addObs(Column column, Obs obs) {
if (!column.pointSetByConceptUuid.containsKey(obs.conceptUuid)) {
column.pointSetByConceptUuid.put(obs.conceptUuid, new TreeSet<ObsPoint>());
}
ObsPoint point = obs.getObsPoint();
if (point != null) {
column.pointSetByConceptUuid.get(obs.conceptUuid).add(point);
}
}
/** Exports a map of concept IDs to arrays of [columnStart, points] pairs. */
JSONObject getJsonDataDump() {
JSONObject dump = new JSONObject();
for (String uuid : mConceptsToDump) {
try {
JSONArray pointGroups = new JSONArray();
for (Column column : mColumnsByStartMillis.values()) {
JSONArray pointArray = new JSONArray();
SortedSet<ObsPoint> points = column.pointSetByConceptUuid.get(uuid);
if (points != null && points.size() > 0) {
for (ObsPoint point : points) {
pointArray.put(point.toJson());
}
JSONObject pointGroup = new JSONObject();
pointGroup.put("start", column.start.getMillis());
pointGroup.put("stop", column.stop.getMillis());
pointGroup.put("points", pointArray);
pointGroups.put(pointGroup);
}
}
dump.put("" + Utils.compressUuid(uuid), pointGroups);
} catch (JSONException e) {
LOG.e(e, "JSON error while dumping chart data");
}
}
return dump;
}
// TODO: grouped coded concepts (for select-multiple, e.g. types of bleeding, types of pain)
// TODO: concept tags for formatting hints (e.g. none/mild/moderate/severe, abbreviated)
String getHtml() {
Map<String, Object> context = new HashMap<>();
context.put("tileRows", mTileRows);
context.put("rows", mRows);
context.put("columns", Lists.newArrayList(mColumnsByStartMillis.values()));
context.put("nowColumnStart", mNowColumn.start);
context.put("orders", mOrders);
context.put("dataCellsByConceptId", getJsonDataDump());
return renderTemplate("assets/chart.html", context);
}
/**
* Inserts empty columns to fill in the gaps between the existing columns, wherever
* the gap can be filled by inserting fewer than 3 adjacent empty columns.
*/
void insertEmptyColumns() {
List<DateTime> starts = new ArrayList<>();
for (Long startMillis : mColumnsByStartMillis.keySet()) {
starts.add(new DateTime(startMillis));
}
DateTime prev = starts.get(0);
for (DateTime next : starts) {
if (!next.isAfter(prev.plusDays(3))) {
for (DateTime dt = prev.plusDays(1); dt.isBefore(next); dt = dt.plusDays(1)) {
getColumnContainingTime(dt); // creates a column if it doesn't exist yet
}
}
prev = next;
}
}
/** Renders a Pebble template. */
String renderTemplate(String filename, Map<String, Object> context) {
if (sEngine == null) {
// PebbleEngine caches compiled templates by filename, so as long as we keep using the
// same engine instance, it's okay to call getTemplate(filename) on each render.
sEngine = new PebbleEngine();
sEngine.addExtension(new PebbleExtension());
}
try {
StringWriter writer = new StringWriter();
sEngine.getTemplate(filename).evaluate(writer, context);
return writer.toString();
} catch (Exception e) {
StringWriter writer = new StringWriter();
e.printStackTrace(new PrintWriter(writer));
return "<div style=\"font-size: 150%\">" + writer.toString().replace("&", "&").replace("<", "<").replace("\n", "<br>");
}
}
}
}