From 10de6571925a3294ead4220297f5039101f26f90 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 23 Oct 2026 11:49:52 +0300 Subject: [PATCH 1/2] fix(charts): axis labels fit, wrap or turn instead of overlapping; the SVG export reads the same X labels were cut at ~10 characters and ran into each other. One planner (ChartAxisLabels.planX) now picks, for screen and SVG alike: one line, two lines, turned 45 degrees, and only as the last resort every nth label. The left axis no longer shows the data maximum among round ticks (749.99 over 600). The pie legend formats values like the tooltips and shows the full label on hover. The SVG gets formatted ticks (1.2M), axis names and a bottom margin that follows the labels. --- .../results/charts/chart_axis_labels.dart | 130 ++++++++++++++++++ lib/features/results/charts/chart_svg.dart | 122 +++++++++++++--- .../results/charts/quick_chart_view.dart | 91 +++++++----- .../charts/chart_axis_labels_test.dart | 70 ++++++++++ .../results/charts/chart_svg_test.dart | 42 ++++++ 5 files changed, 402 insertions(+), 53 deletions(-) create mode 100644 lib/features/results/charts/chart_axis_labels.dart create mode 100644 test/features/results/charts/chart_axis_labels_test.dart 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..1c184602 --- /dev/null +++ b/lib/features/results/charts/chart_axis_labels.dart @@ -0,0 +1,130 @@ +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 = 140; + + /// 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 + 10, + ); + } + 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 + 10, + ); + } + + if (room > 0 && _wraps(labels, widths, room, measure)) { + return XLabelPlan( + layout: XLabelLayout.wrapped, + step: 1, + slot: slot, + labelMaxWidth: slot, + reserved: lineHeight * 2 + 10, + ); + } + + // A turned label needs about a line and a third of height between two. + final step = math.max(1, (lineHeight * 1.3 / 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..84e47e08 --- /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, 23); + }); + + 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 + 10); + }); + + 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); + // (140 + 13) * sin(45 deg) + 10 + expect(p.reserved, closeTo(118.2, 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 a line and a third apart. + expect(p.slot, greaterThanOrEqualTo(13 * 1.3)); + }); + + 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(' Date: Fri, 23 Oct 2026 11:55:52 +0300 Subject: [PATCH 2/2] fix(charts): turned labels reserve the height they take; spacing follows the 45-degree geometry --- lib/features/results/charts/chart_axis_labels.dart | 13 +++++++------ .../results/charts/chart_axis_labels_test.dart | 12 ++++++------ .../results/charts/quick_chart_view_test.dart | 6 ++++-- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/lib/features/results/charts/chart_axis_labels.dart b/lib/features/results/charts/chart_axis_labels.dart index 1c184602..0889d9c3 100644 --- a/lib/features/results/charts/chart_axis_labels.dart +++ b/lib/features/results/charts/chart_axis_labels.dart @@ -41,7 +41,7 @@ class XLabelPlan { /// 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 = 140; + static const double maxRotatedWidth = 110; /// Space kept between two neighbouring labels. static const double gap = 8; @@ -63,7 +63,7 @@ abstract final class ChartAxisLabels { step: 1, slot: plotWidth, labelMaxWidth: plotWidth, - reserved: lineHeight + 10, + reserved: lineHeight + 15, ); } final slot = plotWidth / count; @@ -77,7 +77,7 @@ abstract final class ChartAxisLabels { step: 1, slot: slot, labelMaxWidth: slot, - reserved: lineHeight + 10, + reserved: lineHeight + 15, ); } @@ -87,12 +87,13 @@ abstract final class ChartAxisLabels { step: 1, slot: slot, labelMaxWidth: slot, - reserved: lineHeight * 2 + 10, + reserved: lineHeight * 2 + 15, ); } - // A turned label needs about a line and a third of height between two. - final step = math.max(1, (lineHeight * 1.3 / slot).ceil()); + // 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, diff --git a/test/features/results/charts/chart_axis_labels_test.dart b/test/features/results/charts/chart_axis_labels_test.dart index 84e47e08..d1d6fe25 100644 --- a/test/features/results/charts/chart_axis_labels_test.dart +++ b/test/features/results/charts/chart_axis_labels_test.dart @@ -16,7 +16,7 @@ void main() { final p = plan(['Jan', 'Feb', 'Mar', 'Apr'], 400); expect(p.layout, XLabelLayout.horizontal); expect(p.step, 1); - expect(p.reserved, 23); + expect(p.reserved, 28); }); test('labels with spaces wrap to two lines before they are turned', () { @@ -29,7 +29,7 @@ void main() { ], 400); expect(p.layout, XLabelLayout.wrapped); expect(p.step, 1); - expect(p.reserved, 13 * 2 + 10); + expect(p.reserved, 13 * 2 + 15); }); test('a long word cannot wrap, so the labels are turned and all kept', () { @@ -43,8 +43,8 @@ void main() { final p = plan(['x' * 100, 'y' * 100], 120); expect(p.layout, XLabelLayout.rotated); expect(p.labelMaxWidth, ChartAxisLabels.maxRotatedWidth); - // (140 + 13) * sin(45 deg) + 10 - expect(p.reserved, closeTo(118.2, 0.1)); + // (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', () { @@ -52,8 +52,8 @@ void main() { final p = plan(labels, 700); expect(p.layout, XLabelLayout.rotated); expect(p.step, greaterThan(1)); - // Two drawn labels are at least a line and a third apart. - expect(p.slot, greaterThanOrEqualTo(13 * 1.3)); + // 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', () { diff --git a/test/features/results/charts/quick_chart_view_test.dart b/test/features/results/charts/quick_chart_view_test.dart index c1697238..edffbd1e 100644 --- a/test/features/results/charts/quick_chart_view_test.dart +++ b/test/features/results/charts/quick_chart_view_test.dart @@ -230,7 +230,8 @@ void main() { .bottomTitles .sideTitles .reservedSize; - expect(many, 52); + // Turned labels reserve the height they take at 45 degrees, not a fixed 52. + expect(many, greaterThan(80)); await pumpView(t, columns, rows); await t.tap(find.bySemanticsLabel('Bar')); @@ -315,7 +316,8 @@ void main() { final labels = find.textContaining('a long category label number'); final n = labels.evaluate().length; - expect(n, inInclusiveRange(2, 8), reason: 'one label per step of five'); + expect(n, inInclusiveRange(2, 20), + reason: 'every nth label, as many as clear each other at 45 degrees'); final xs = [for (var i = 0; i < n; i++) t.getCenter(labels.at(i)).dx] ..sort(); final lineHeight = t.getSize(labels.first).height;