Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions lib/features/results/charts/chart_axis_labels.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import 'dart:math' as math;

/// How the category labels under a bar / line chart are laid out.
enum XLabelLayout {
/// One line each, whole text.
horizontal,

/// Up to two lines each, whole words.
wrapped,

/// One line each, turned 45°, cut at [XLabelPlan.labelMaxWidth].
rotated,
}

/// The layout [ChartAxisLabels.planX] picked.
class XLabelPlan {
const XLabelPlan({
required this.layout,
required this.step,
required this.slot,
required this.labelMaxWidth,
required this.reserved,
});

final XLabelLayout layout;

/// Every [step]th label is drawn; 1 draws all of them.
final int step;

/// Width each drawn label may take: the distance between two drawn labels.
final double slot;

/// Widest a rotated label gets before it is cut; the slot otherwise.
final double labelMaxWidth;

/// Height the axis reserves under the plot for the labels.
final double reserved;
}

/// Layout rules for chart axis labels, shared by the screen and the SVG export
/// so the two read the same. Pure: text widths come from a callback.
abstract final class ChartAxisLabels {
/// A rotated label is cut at this width.
static const double maxRotatedWidth = 110;

/// Space kept between two neighbouring labels.
static const double gap = 8;

/// Picks how the [labels] fit under a plot [plotWidth] wide, in this order:
/// every label on one line; every label on two lines; every label turned 45°;
/// every nth label turned 45°. Dropping labels is the last resort, since the
/// bar or point is still there but nothing says what it is.
static XLabelPlan planX({
required List<String> labels,
required double plotWidth,
required double Function(String text) measure,
required double lineHeight,
}) {
final count = labels.length;
if (count == 0) {
return XLabelPlan(
layout: XLabelLayout.horizontal,
step: 1,
slot: plotWidth,
labelMaxWidth: plotWidth,
reserved: lineHeight + 15,
);
}
final slot = plotWidth / count;
final room = slot - gap;
final widths = [for (final l in labels) measure(l)];
final widest = widths.reduce(math.max);

if (widest <= room) {
return XLabelPlan(
layout: XLabelLayout.horizontal,
step: 1,
slot: slot,
labelMaxWidth: slot,
reserved: lineHeight + 15,
);
}

if (room > 0 && _wraps(labels, widths, room, measure)) {
return XLabelPlan(
layout: XLabelLayout.wrapped,
step: 1,
slot: slot,
labelMaxWidth: slot,
reserved: lineHeight * 2 + 15,
);
}

// Two neighbours turned 45° clear each other when they are a line height
// over sin 45° apart; 1.5 lines leaves a little air.
final step = math.max(1, (lineHeight * 1.5 / slot).ceil());
final cap = math.min(widest, maxRotatedWidth);
return XLabelPlan(
layout: XLabelLayout.rotated,
step: step,
slot: slot * step,
labelMaxWidth: cap,
reserved: (cap + lineHeight) * math.sqrt1_2 + 10,
);
}

/// Whether every label breaks into at most two lines of [room] width: its
/// longest word fits, and the whole text fits twice over (with some slack,
/// since a break wastes the end of the first line).
static bool _wraps(
List<String> labels,
List<double> widths,
double room,
double Function(String) measure,
) {
for (var i = 0; i < labels.length; i++) {
if (widths[i] <= room) continue;
final words = labels[i].split(RegExp(r'\s+'));
if (words.length < 2) return false;
if (words.map(measure).reduce(math.max) > room) return false;
if (widths[i] > room * 2 * 0.85) return false;
}
return true;
}

/// [text] cut to at most [maxChars] characters, with an ellipsis.
static String ellipsize(String text, int maxChars) {
if (maxChars < 2 || text.length <= maxChars) return text;
return '${text.substring(0, maxChars - 1).trimRight()}…';
}
}
122 changes: 100 additions & 22 deletions lib/features/results/charts/chart_svg.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'dart:math' as math;

import 'package:querya_desktop/features/results/charts/chart_axis_labels.dart';
import 'package:querya_desktop/features/results/charts/chart_data.dart';
import 'package:querya_desktop/features/results/charts/chart_format.dart';

/// Colors used by [ChartSvg], as `#rrggbb` strings.
class ChartSvgColors {
Expand Down Expand Up @@ -41,7 +43,13 @@ class ChartSvg {
static const double _left = 64;
static const double _right = 24;
static const double _top = 24;
static const double _bottom = 56;
static const double _fontSize = 11;

/// Average glyph width in `sans-serif` at [_fontSize], for fitting labels
/// without a text engine.
static const double _glyph = _fontSize * 0.58;

static double _measure(String text) => text.length * _glyph;

static String escape(String s) => s
.replaceAll('&', '&amp;')
Expand All @@ -51,31 +59,29 @@ class ChartSvg {

static String _n(double v) => v.toStringAsFixed(2);

static String _tick(double v) {
if (v == v.roundToDouble()) return v.toInt().toString();
return v.toStringAsFixed(2);
}

static String build({
required List<ChartPoint> points,
required QuickChartType type,
required ChartSvgColors colors,
String? title,
String? xName,
String? yName,
}) {
final b = StringBuffer()
..writeln('<?xml version="1.0" encoding="UTF-8"?>')
..writeln('<svg xmlns="http://www.w3.org/2000/svg" '
'width="${width.toInt()}" height="${height.toInt()}" '
'viewBox="0 0 ${width.toInt()} ${height.toInt()}" '
'font-family="sans-serif" font-size="11">');
'font-family="sans-serif" font-size="${_fontSize.toInt()}">');
if (title != null) b.writeln('<title>${escape(title)}</title>');
b.writeln('<rect width="100%" height="100%" fill="${colors.background}"/>');
if (points.isNotEmpty) {
switch (type) {
case QuickChartType.bar:
_axes(b, points, colors, bars: true);
_axes(b, points, colors, bars: true, xName: xName, yName: yName);
case QuickChartType.line:
_axes(b, points, colors, bars: false);
_axes(b, points, colors, bars: false, xName: xName, yName: yName);
case QuickChartType.pie:
_pie(b, points, colors);
}
Expand All @@ -89,9 +95,25 @@ class ChartSvg {
List<ChartPoint> points,
ChartSvgColors colors, {
required bool bars,
String? xName,
String? yName,
}) {
const plotW = width - _left - _right;
const plotH = height - _top - _bottom;
// Line charts on a date X column place points at their real distance and
// label six dates; the other charts label the points themselves.
final offsets = bars ? null : ChartData.timeOffsets(points);
final plan = offsets != null
? null
: ChartAxisLabels.planX(
labels: [for (final p in points) p.label],
plotWidth: plotW,
measure: _measure,
lineHeight: _fontSize * 1.3,
);
final labelsH = plan?.reserved ?? _fontSize * 1.3 + 10;
final nameH = xName == null ? 0.0 : _fontSize * 1.6;
final bottom = labelsH + nameH + 8;
final plotH = height - _top - bottom;
var lo = math.min(0.0, points.map((p) => p.value).reduce(math.min));
var hi = math.max(0.0, points.map((p) => p.value).reduce(math.max));
if (hi == lo) hi = lo + 1;
Expand All @@ -106,22 +128,22 @@ class ChartSvg {
'x2="${_n(width - _right)}" y2="${_n(py)}" '
'stroke="${colors.grid}" stroke-width="1"/>')
..writeln('<text x="${_n(_left - 6)}" y="${_n(py + 4)}" '
'text-anchor="end" fill="${colors.text}">${escape(_tick(v))}</text>');
'text-anchor="end" fill="${colors.text}">'
'${escape(ChartFormat.compact(v))}</text>');
}

final slot = plotW / points.length;
final step = (points.length / 8).ceil().clamp(1, 1000);
final step = plan?.step ?? 1;
final fill = colors.series.first;
final zero = y(0);
final line = <String>[];
// Line charts on a date X column place points at their real distance.
final offsets = bars ? null : ChartData.timeOffsets(points);
final spanDays = offsets == null ? 0.0 : offsets.reduce(math.max);
double xAt(int i) => offsets == null
? _left + slot * (i + 0.5)
: spanDays == 0
? _left + plotW / 2
: _left + plotW * offsets[i] / spanDays;
final labelY = height - bottom + nameH + 16;
for (var i = 0; i < points.length; i++) {
final cx = xAt(i);
final py = y(points[i].value);
Expand All @@ -131,14 +153,12 @@ class ChartSvg {
b.writeln('<rect x="${_n(cx - w / 2)}" y="${_n(top)}" '
'width="${_n(w)}" height="${_n((py - zero).abs())}" '
'fill="$fill"><title>${escape(points[i].label)}: '
'${escape(_tick(points[i].value))}</title></rect>');
'${escape(ChartFormat.full(points[i].value))}</title></rect>');
} else {
line.add('${_n(cx)},${_n(py)}');
}
if (offsets == null && i % step == 0) {
b.writeln('<text x="${_n(cx)}" y="${_n(height - _bottom + 18)}" '
'text-anchor="middle" fill="${colors.text}">'
'${escape(points[i].label)}</text>');
if (plan != null && i % step == 0) {
_xLabel(b, points[i].label, cx, labelY - nameH, plan, colors);
}
}
if (offsets != null) {
Expand All @@ -150,7 +170,7 @@ class ChartSvg {
microseconds:
(spanDays * Duration.microsecondsPerDay * k / 6).round()));
final x = spanDays == 0 ? _left + plotW / 2 : _left + plotW * k / 6;
b.writeln('<text x="${_n(x)}" y="${_n(height - _bottom + 18)}" '
b.writeln('<text x="${_n(x)}" y="${_n(labelY - nameH)}" '
'text-anchor="middle" fill="${colors.text}">'
'${escape(ChartData.timeTickLabel(at, span))}</text>');
}
Expand All @@ -160,13 +180,71 @@ class ChartSvg {
'stroke="$fill" stroke-width="2"/>');
if (points.length <= 50) {
for (var i = 0; i < points.length; i++) {
final cx = _left + slot * (i + 0.5);
final cx = xAt(i);
b.writeln('<circle cx="${_n(cx)}" cy="${_n(y(points[i].value))}" '
'r="3" fill="$fill"><title>${escape(points[i].label)}: '
'${escape(_tick(points[i].value))}</title></circle>');
'${escape(ChartFormat.full(points[i].value))}</title></circle>');
}
}
}
if (xName != null) {
b.writeln('<text x="${_n(_left + plotW / 2)}" y="${_n(height - 8)}" '
'text-anchor="middle" fill="${colors.text}">${escape(xName)}</text>');
}
if (yName != null) {
final cy = _top + plotH / 2;
b.writeln('<text x="12" y="${_n(cy)}" text-anchor="middle" '
'transform="rotate(-90 12 ${_n(cy)})" fill="${colors.text}">'
'${escape(yName)}</text>');
}
}

/// One X label under the point at [cx], laid out as [plan] says.
static void _xLabel(
StringBuffer b,
String label,
double cx,
double baseY,
XLabelPlan plan,
ChartSvgColors colors,
) {
switch (plan.layout) {
case XLabelLayout.horizontal:
b.writeln('<text x="${_n(cx)}" y="${_n(baseY)}" text-anchor="middle" '
'fill="${colors.text}">${escape(label)}</text>');
case XLabelLayout.wrapped:
final lines = _wrap(label, plan.slot - ChartAxisLabels.gap);
b.write('<text x="${_n(cx)}" y="${_n(baseY)}" text-anchor="middle" '
'fill="${colors.text}">');
for (var l = 0; l < lines.length; l++) {
b.write('<tspan x="${_n(cx)}" dy="${l == 0 ? 0 : _fontSize * 1.3}">'
'${escape(lines[l])}</tspan>');
}
b.writeln('</text>');
case XLabelLayout.rotated:
final cut = ChartAxisLabels.ellipsize(
label, (plan.labelMaxWidth / _glyph).floor());
b.writeln('<text x="${_n(cx)}" y="${_n(baseY - 4)}" text-anchor="end" '
'transform="rotate(-45 ${_n(cx)} ${_n(baseY - 4)})" '
'fill="${colors.text}">${escape(cut)}</text>');
}
}

/// [text] broken into at most two lines of [room] width, on spaces; the
/// second line is cut with an ellipsis if it still does not fit.
static List<String> _wrap(String text, double room) {
final words = text.split(RegExp(r'\s+'));
var first = '';
var i = 0;
while (i < words.length) {
final next = first.isEmpty ? words[i] : '$first ${words[i]}';
if (first.isNotEmpty && _measure(next) > room) break;
first = next;
i++;
}
final rest = words.sublist(i).join(' ');
if (rest.isEmpty) return [first];
return [first, ChartAxisLabels.ellipsize(rest, (room / _glyph).floor())];
}

static void _pie(
Expand All @@ -181,7 +259,7 @@ class ChartSvg {
final fill = colors.series[i % colors.series.length];
final sweep = points[i].value / total * 2 * math.pi;
final tip = '<title>${escape(points[i].label)}: '
'${escape(_tick(points[i].value))}</title>';
'${escape(ChartFormat.full(points[i].value))}</title>';
if (points.length == 1) {
b.writeln('<circle cx="$cx" cy="$cy" r="$r" fill="$fill">$tip</circle>');
} else {
Expand Down
Loading
Loading