diff --git a/lib/features/results/charts/chart_axis_labels.dart b/lib/features/results/charts/chart_axis_labels.dart new file mode 100644 index 00000000..0889d9c3 --- /dev/null +++ b/lib/features/results/charts/chart_axis_labels.dart @@ -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 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 labels, + List 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()}…'; + } +} diff --git a/lib/features/results/charts/chart_svg.dart b/lib/features/results/charts/chart_svg.dart index 50bdeedc..46e5bd86 100644 --- a/lib/features/results/charts/chart_svg.dart +++ b/lib/features/results/charts/chart_svg.dart @@ -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 { @@ -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('&', '&') @@ -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 points, required QuickChartType type, required ChartSvgColors colors, String? title, + String? xName, + String? yName, }) { final b = StringBuffer() ..writeln('') ..writeln(''); + 'font-family="sans-serif" font-size="${_fontSize.toInt()}">'); if (title != null) b.writeln('${escape(title)}'); b.writeln(''); 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); } @@ -89,9 +95,25 @@ class ChartSvg { List 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; @@ -106,22 +128,22 @@ class ChartSvg { 'x2="${_n(width - _right)}" y2="${_n(py)}" ' 'stroke="${colors.grid}" stroke-width="1"/>') ..writeln('${escape(_tick(v))}'); + 'text-anchor="end" fill="${colors.text}">' + '${escape(ChartFormat.compact(v))}'); } 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 = []; - // 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); @@ -131,14 +153,12 @@ class ChartSvg { b.writeln('${escape(points[i].label)}: ' - '${escape(_tick(points[i].value))}'); + '${escape(ChartFormat.full(points[i].value))}'); } else { line.add('${_n(cx)},${_n(py)}'); } - if (offsets == null && i % step == 0) { - b.writeln('' - '${escape(points[i].label)}'); + if (plan != null && i % step == 0) { + _xLabel(b, points[i].label, cx, labelY - nameH, plan, colors); } } if (offsets != null) { @@ -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('' '${escape(ChartData.timeTickLabel(at, span))}'); } @@ -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('${escape(points[i].label)}: ' - '${escape(_tick(points[i].value))}'); + '${escape(ChartFormat.full(points[i].value))}'); } } } + if (xName != null) { + b.writeln('${escape(xName)}'); + } + if (yName != null) { + final cy = _top + plotH / 2; + b.writeln('' + '${escape(yName)}'); + } + } + + /// 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('${escape(label)}'); + case XLabelLayout.wrapped: + final lines = _wrap(label, plan.slot - ChartAxisLabels.gap); + b.write(''); + for (var l = 0; l < lines.length; l++) { + b.write('' + '${escape(lines[l])}'); + } + b.writeln(''); + case XLabelLayout.rotated: + final cut = ChartAxisLabels.ellipsize( + label, (plan.labelMaxWidth / _glyph).floor()); + b.writeln('${escape(cut)}'); + } + } + + /// [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 _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( @@ -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 = '${escape(points[i].label)}: ' - '${escape(_tick(points[i].value))}'; + '${escape(ChartFormat.full(points[i].value))}'; if (points.length == 1) { b.writeln('$tip'); } else { diff --git a/lib/features/results/charts/quick_chart_view.dart b/lib/features/results/charts/quick_chart_view.dart index 1c846582..c5408d36 100644 --- a/lib/features/results/charts/quick_chart_view.dart +++ b/lib/features/results/charts/quick_chart_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/widgets.dart'; import 'dart:async' show unawaited; import 'dart:convert' show utf8; -import 'dart:math' show min, pi; +import 'dart:math' show max, min, pi; import 'dart:typed_data'; import 'dart:ui' as ui; @@ -11,6 +11,8 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/rendering.dart'; import 'package:querya_desktop/core/export/svg_png.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/ui/querya_tooltip.dart'; +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'; import 'package:querya_desktop/features/results/charts/chart_svg.dart'; @@ -226,6 +228,8 @@ class _QuickChartViewState extends material.State { points: _series(valueCol).points, type: _type, title: _chartTitle(), + xName: _xName, + yName: _yName, colors: currentTheme ? ChartSvgColors( background: ChartSvgColors.hex(wb.surface.toARGB32()), @@ -490,9 +494,7 @@ class _QuickChartViewState extends material.State { final p = points[i]; final share = total > 0 ? p.value / total * 100 : 0.0; final hovered = _hoveredSlice == i; - final valueText = p.value == p.value.roundToDouble() - ? p.value.toInt().toString() - : p.value.toStringAsFixed(2); + final valueText = ChartFormat.full(p.value); return material.MouseRegion( onEnter: (_) => setState(() => _hoveredSlice = i), onExit: (_) => setState(() => _hoveredSlice = null), @@ -505,10 +507,14 @@ class _QuickChartViewState extends material.State { width: 10, height: 10, color: colorOf(i)), const material.SizedBox(width: 6), material.Expanded( - child: material.Text(p.label, - maxLines: 1, - overflow: material.TextOverflow.ellipsis, - style: const material.TextStyle(fontSize: 12)), + child: material.Tooltip( + message: p.label, + waitDuration: kQueryaTooltipWait, + child: material.Text(p.label, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: const material.TextStyle(fontSize: 12)), + ), ), const material.SizedBox(width: 8), material.Text(valueText, @@ -593,18 +599,29 @@ class _QuickChartViewState extends material.State { builder: (context, box) => _axisChart(context, points, box.maxWidth)); } - /// Bar and line charts. [width] decides whether X labels fit or rotate. + /// Bar and line charts. [width] decides how the X labels are laid out + /// ([ChartAxisLabels.planX]). material.Widget _axisChart( material.BuildContext context, List points, double width) { final palette = context.semanticPalette; final wb = context.workbench; final cs = context.colors; final label = material.TextStyle(color: wb.mutedForeground, fontSize: 10); - final step = (points.length / 8).ceil().clamp(1, 1000); - // Labels shown: one per step. Rotate them when a slot is too narrow. - final shown = (points.length / step).ceil().clamp(1, 1000); - final slot = (width - 60) / shown; - final rotate = slot < 60; + // The left axis takes 52 px and the axis name 18; the rest is the plot. + final plotWidth = max(width - 70, 1.0); + double measure(String text) => (TextPainter( + text: TextSpan(text: text, style: label), + textDirection: TextDirection.ltr, + maxLines: 1, + )..layout()) + .width; + final plan = ChartAxisLabels.planX( + labels: [for (final p in points) p.label], + plotWidth: plotWidth, + measure: measure, + lineHeight: (label.fontSize ?? 10) * 1.3, + ); + final step = plan.step; // Line charts on a date X column place points at their real distance; the // axis then shows dates instead of point labels. @@ -634,25 +651,29 @@ class _QuickChartViewState extends material.State { final text = material.Text( points[i].label, style: label, - maxLines: 1, + maxLines: plan.layout == XLabelLayout.wrapped ? 2 : 1, + textAlign: TextAlign.center, + softWrap: plan.layout == XLabelLayout.wrapped, overflow: material.TextOverflow.ellipsis, ); return SideTitleWidget( meta: meta, - child: rotate - ? material.Transform.rotate( - angle: -pi / 4, - alignment: material.Alignment.topRight, - child: material.ConstrainedBox( - constraints: - const material.BoxConstraints(maxWidth: 120), - child: text, - ), - ) - : material.ConstrainedBox( - constraints: material.BoxConstraints(maxWidth: slot), + child: switch (plan.layout) { + XLabelLayout.rotated => material.Transform.rotate( + angle: -pi / 4, + alignment: material.Alignment.topRight, + child: material.ConstrainedBox( + constraints: + material.BoxConstraints(maxWidth: plan.labelMaxWidth), child: text, ), + ), + _ => material.ConstrainedBox( + constraints: material.BoxConstraints( + maxWidth: max(plan.slot - ChartAxisLabels.gap, 1.0)), + child: text, + ), + }, ); } @@ -665,7 +686,7 @@ class _QuickChartViewState extends material.State { axisNameWidget: material.Text(_xName, style: axisLabel), sideTitles: SideTitles( showTitles: true, - reservedSize: rotate ? 52 : 28, + reservedSize: plan.reserved, interval: timeTick, getTitlesWidget: bottom), ), @@ -675,9 +696,17 @@ class _QuickChartViewState extends material.State { sideTitles: SideTitles( showTitles: true, reservedSize: 52, - getTitlesWidget: (v, meta) => SideTitleWidget( - meta: meta, - child: material.Text(ChartFormat.compact(v), style: label)), + getTitlesWidget: (v, meta) { + // The axis ends at the data maximum, which is rarely a multiple of + // the step; its label would sit among round ones (749.99 over 600). + final steps = (v - meta.min) / meta.appliedInterval; + if ((steps - steps.round()).abs() > 1e-6) { + return const material.SizedBox.shrink(); + } + return SideTitleWidget( + meta: meta, + child: material.Text(ChartFormat.compact(v), style: label)); + }, ), ), ); diff --git a/test/features/results/charts/chart_axis_labels_test.dart b/test/features/results/charts/chart_axis_labels_test.dart new file mode 100644 index 00000000..d1d6fe25 --- /dev/null +++ b/test/features/results/charts/chart_axis_labels_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/results/charts/chart_axis_labels.dart'; + +void main() { + // 6 px per character, 13 px line. + double measure(String t) => t.length * 6.0; + XLabelPlan plan(List labels, double plotWidth) => + ChartAxisLabels.planX( + labels: labels, + plotWidth: plotWidth, + measure: measure, + lineHeight: 13, + ); + + test('short labels stay on one line, all of them', () { + final p = plan(['Jan', 'Feb', 'Mar', 'Apr'], 400); + expect(p.layout, XLabelLayout.horizontal); + expect(p.step, 1); + expect(p.reserved, 28); + }); + + test('labels with spaces wrap to two lines before they are turned', () { + // 4 labels of 20 chars = 120 px in a 100 px slot; words are 6-10 chars. + final p = plan([ + 'Wireless Mouse Pro', + 'Standing Desk Mat', + 'Studio Headphones', + 'Multiport Dock USB', + ], 400); + expect(p.layout, XLabelLayout.wrapped); + expect(p.step, 1); + expect(p.reserved, 13 * 2 + 15); + }); + + test('a long word cannot wrap, so the labels are turned and all kept', () { + final p = plan(['a' * 30, 'b' * 30], 150); + expect(p.layout, XLabelLayout.rotated); + expect(p.step, 1); + expect(p.labelMaxWidth, ChartAxisLabels.maxRotatedWidth); + }); + + test('turned labels are cut at the maximum width', () { + final p = plan(['x' * 100, 'y' * 100], 120); + expect(p.layout, XLabelLayout.rotated); + expect(p.labelMaxWidth, ChartAxisLabels.maxRotatedWidth); + // (110 + 13) * sin(45 deg) + 10 + expect(p.reserved, closeTo(97.0, 0.1)); + }); + + test('many points thin the turned labels out, as the last resort', () { + final labels = [for (var i = 0; i < 200; i++) 'category-$i']; + final p = plan(labels, 700); + expect(p.layout, XLabelLayout.rotated); + expect(p.step, greaterThan(1)); + // Two drawn labels are at least 1.5 lines apart. + expect(p.slot, greaterThanOrEqualTo(13 * 1.5)); + }); + + test('no labels give a plan without dividing by zero', () { + final p = plan(const [], 300); + expect(p.step, 1); + expect(p.slot, 300); + }); + + test('ellipsize keeps short text and cuts long text with one character', () { + expect(ChartAxisLabels.ellipsize('short', 10), 'short'); + expect(ChartAxisLabels.ellipsize('a very long label', 8), 'a very…'); + expect(ChartAxisLabels.ellipsize('abc', 1), 'abc'); + }); +} diff --git a/test/features/results/charts/chart_svg_test.dart b/test/features/results/charts/chart_svg_test.dart index 293c3688..8b006791 100644 --- a/test/features/results/charts/chart_svg_test.dart +++ b/test/features/results/charts/chart_svg_test.dart @@ -88,4 +88,46 @@ void main() { expect(svg, isNot(contains('NaN'))); expect(svg, isNot(contains('Infinity'))); }); + + test('axis ticks and tooltips use the screen formatting', () { + final svg = build(QuickChartType.bar, const [ + ChartPoint('a', 1500000), + ChartPoint('b', 3000000), + ]); + expect(svg, contains('>3M')); + expect(svg, contains('a: 1,500,000')); + expect(svg, isNot(contains('3000000'))); + }); + + test('the axes carry their names when given', () { + final svg = ChartSvg.build( + points: points, + type: QuickChartType.bar, + colors: colors, + xName: 'title', + yName: 'price', + ); + expect(svg, contains('>title')); + expect(svg, contains('rotate(-90 ')); + expect(svg, contains('>price')); + }); + + test('labels that do not fit side by side are turned, not overlapped', () { + final svg = build(QuickChartType.bar, [ + for (var i = 0; i < 10; i++) + ChartPoint('Noise-Cancelling-Studio-Headphones-$i', i + 1), + ]); + expect(svg, contains('rotate(-45 ')); + // Every point keeps its label, cut with an ellipsis. + expect('rotate(-45 '.allMatches(svg).length, 10); + expect(svg, contains('…')); + }); + + test('labels with spaces wrap onto a second line', () { + final svg = build(QuickChartType.bar, [ + for (var i = 0; i < 8; i++) ChartPoint('Ergonomic Keyboard $i', i + 1), + ]); + expect(svg, contains('