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
108 changes: 104 additions & 4 deletions lib/features/results/charts/chart_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ enum ChartAggregation { none, sum, count, avg, min, max }

/// One labelled point of a chart series.
class ChartPoint {
const ChartPoint(this.label, this.value);
const ChartPoint(this.label, this.value, {this.time});

final String label;
final double value;

/// The label as a date or timestamp, when the X column holds dates.
final DateTime? time;
}

/// Points ready to plot, plus how many parsed rows were left out of them.
Expand Down Expand Up @@ -42,6 +45,96 @@ class ChartData {
/// Name of the slice that collects the categories beyond Top N.
static const String otherLabel = 'Other';

static final _isoTime = RegExp(
r'^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?(Z|[+-]\d{2}:?\d{2})?$',
);
static final _hasZone = RegExp(r'(Z|[+-]\d{2}:?\d{2})$');

/// Parses an ISO date (`2026-01-31`) or timestamp (`2026-01-31 10:00`,
/// `2026-01-31T10:00:00Z`). Without a zone the wall-clock fields are kept, so
/// a day stays a day whatever the machine's time zone is.
static DateTime? parseTime(String raw) {
final text = raw.trim();
if (!_isoTime.hasMatch(text)) return null;
final parsed = DateTime.tryParse(text.replaceFirst(' ', 'T'));
if (parsed == null) return null;
// DateTime rolls an impossible date over (2026-13-45 becomes 2027-02-14);
// the fields must read back as typed.
if (parsed.year != int.parse(text.substring(0, 4)) ||
parsed.month != int.parse(text.substring(5, 7)) ||
parsed.day != int.parse(text.substring(8, 10))) {
return null;
}
if (text.length >= 16 &&
(parsed.hour != int.parse(text.substring(11, 13)) ||
parsed.minute != int.parse(text.substring(14, 16)))) {
return null;
}
if (_hasZone.hasMatch(text)) return parsed.toUtc();
return DateTime.utc(parsed.year, parsed.month, parsed.day, parsed.hour,
parsed.minute, parsed.second, parsed.millisecond, parsed.microsecond);
}

/// Whether every non-empty cell of [column] is a date or timestamp.
static bool isTimeColumn(List<List<String>> rows, int column) {
var seen = 0;
for (final row in rows) {
if (column >= row.length) continue;
final cell = row[column].trim();
if (cell.isEmpty) continue;
if (parseTime(cell) == null) return false;
seen++;
}
return seen > 0;
}

/// Earliest time among [points]; null unless every point has a time.
static DateTime? timeOrigin(List<ChartPoint> points) {
if (points.isEmpty || points.any((p) => p.time == null)) return null;
return points.map((p) => p.time!).reduce((a, b) => a.isBefore(b) ? a : b);
}

/// Days from the earliest point for every point, so the X axis places them
/// at their real distance. Null unless every point has a time.
static List<double>? timeOffsets(List<ChartPoint> points) {
final origin = timeOrigin(points);
if (origin == null) return null;
return [
for (final p in points)
p.time!.difference(origin).inMicroseconds / Duration.microsecondsPerDay,
];
}

/// Length of the time axis: from the earliest point to the latest.
static Duration timeSpan(List<ChartPoint> points) {
final origin = timeOrigin(points);
if (origin == null) return Duration.zero;
final last = points.map((p) => p.time!).reduce((a, b) => a.isAfter(b) ? a : b);
return last.difference(origin);
}

/// Tick label for [time] on an axis spanning [span]: hours for a span under
/// two days, days under about 13 months, months beyond.
static String timeTickLabel(DateTime time, Duration span) {
String two(int v) => v.toString().padLeft(2, '0');
if (span < const Duration(days: 2)) {
return '${two(time.hour)}:${two(time.minute)}';
}
if (span < const Duration(days: 400)) {
return '${time.year}-${two(time.month)}-${two(time.day)}';
}
return '${time.year}-${two(time.month)}';
}

/// Index of the point whose X offset is nearest to [x].
static int nearestOffset(List<double> offsets, double x) {
var best = 0;
for (var i = 1; i < offsets.length; i++) {
if ((offsets[i] - x).abs() < (offsets[best] - x).abs()) best = i;
}
return best;
}

/// Parses a cell as a finite number; `null` for empty / non numeric cells.
static double? parseNumber(String raw) {
final text = raw.trim();
Expand Down Expand Up @@ -110,6 +203,12 @@ class ChartData {
}

if (type == QuickChartType.line) {
// Dates on the X axis plot in time order, at their real distance.
if (labelColumn != null &&
isTimeColumn(rows, labelColumn) &&
points.every((p) => p.time != null)) {
points = [...points]..sort((a, b) => a.time!.compareTo(b.time!));
}
if (points.length <= maxPoints) return ChartSeries(points: points);
return ChartSeries(
points: points.sublist(0, maxPoints),
Expand Down Expand Up @@ -191,7 +290,7 @@ class ChartData {
final label = labelColumn != null && labelColumn < row.length
? row[labelColumn]
: '${i + 1}';
points.add(ChartPoint(label, v));
points.add(ChartPoint(label, v, time: parseTime(label)));
}
return points;
}
Expand All @@ -203,17 +302,18 @@ class ChartData {
) {
final groups = <String, _Group>{};
for (final p in points) {
groups.putIfAbsent(p.label, _Group.new).add(p.value);
groups.putIfAbsent(p.label, () => _Group()..time = p.time).add(p.value);
}
return [
for (final e in groups.entries)
ChartPoint(e.key, e.value.result(aggregation)),
ChartPoint(e.key, e.value.result(aggregation), time: e.value.time),
];
}
}

/// Running totals for one X label.
class _Group {
DateTime? time;
double sum = 0;
int count = 0;
double min = double.infinity;
Expand Down
26 changes: 24 additions & 2 deletions lib/features/results/charts/chart_svg.dart
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,16 @@ class ChartSvg {
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;
for (var i = 0; i < points.length; i++) {
final cx = _left + slot * (i + 0.5);
final cx = xAt(i);
final py = y(points[i].value);
if (bars) {
final w = slot * 0.7;
Expand All @@ -118,12 +126,26 @@ class ChartSvg {
} else {
line.add('${_n(cx)},${_n(py)}');
}
if (i % step == 0) {
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 (offsets != null) {
// Six dates on the time axis, labelled for the range they cover.
final origin = ChartData.timeOrigin(points)!;
final span = ChartData.timeSpan(points);
for (var k = 0; k <= 6; k++) {
final at = origin.add(Duration(
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)}" '
'text-anchor="middle" fill="${colors.text}">'
'${escape(ChartData.timeTickLabel(at, span))}</text>');
}
}
if (!bars) {
b.writeln('<polyline points="${line.join(' ')}" fill="none" '
'stroke="$fill" stroke-width="2"/>');
Expand Down
25 changes: 23 additions & 2 deletions lib/features/results/charts/quick_chart_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,27 @@ class _QuickChartViewState extends material.State<QuickChartView> {
final slot = (width - 60) / shown;
final rotate = slot < 60;

// Line charts on a date X column place points at their real distance; the
// axis then shows dates instead of point labels.
final offsets = _type == QuickChartType.line
? ChartData.timeOffsets(points)
: null;
final origin = offsets == null ? null : ChartData.timeOrigin(points);
final span = offsets == null ? Duration.zero : ChartData.timeSpan(points);
final timeTick = offsets == null || span == Duration.zero
? null
: span.inMicroseconds / Duration.microsecondsPerDay / 6;

material.Widget bottom(double v, TitleMeta meta) {
if (offsets != null && origin != null) {
final at = origin.add(Duration(
microseconds: (v * Duration.microsecondsPerDay).round()));
return SideTitleWidget(
meta: meta,
child: material.Text(ChartData.timeTickLabel(at, span),
style: label, maxLines: 1),
);
}
final i = v.toInt();
if (i < 0 || i >= points.length || i % step != 0) {
return const material.SizedBox.shrink();
Expand Down Expand Up @@ -612,6 +632,7 @@ class _QuickChartViewState extends material.State<QuickChartView> {
sideTitles: SideTitles(
showTitles: true,
reservedSize: rotate ? 52 : 28,
interval: timeTick,
getTitlesWidget: bottom),
),
leftTitles: AxisTitles(
Expand Down Expand Up @@ -667,7 +688,7 @@ class _QuickChartViewState extends material.State<QuickChartView> {
getTooltipItems: (spots) => [
for (final s in spots)
LineTooltipItem(
'${points[s.x.toInt()].label}\n${ChartFormat.full(s.y)}',
'${points[offsets == null ? s.x.toInt() : ChartData.nearestOffset(offsets, s.x)].label}\n${ChartFormat.full(s.y)}',
material.TextStyle(
color: cs.popoverForeground, fontSize: 12)),
],
Expand All @@ -677,7 +698,7 @@ class _QuickChartViewState extends material.State<QuickChartView> {
LineChartBarData(
spots: [
for (var i = 0; i < points.length; i++)
FlSpot(i.toDouble(), points[i].value),
FlSpot(offsets?[i] ?? i.toDouble(), points[i].value),
],
color: palette.type1,
barWidth: 2,
Expand Down
102 changes: 102 additions & 0 deletions test/features/results/charts/chart_data_test.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:querya_desktop/features/results/charts/chart_data.dart';
import 'package:querya_desktop/features/results/charts/chart_svg.dart';

void main() {
group('ChartData.parseNumber', () {
Expand Down Expand Up @@ -246,4 +247,105 @@ void main() {
expect(series.droppedRows, 80 - ChartData.maxPoints);
});
});

group('ChartData time axis (#1159)', () {
test('parses ISO dates and timestamps, rejects other formats', () {
expect(ChartData.parseTime('2026-01-31'), DateTime.utc(2026, 1, 31));
expect(ChartData.parseTime('2026-01-31 10:30'),
DateTime.utc(2026, 1, 31, 10, 30));
expect(ChartData.parseTime('2026-01-31T10:30:00Z'),
DateTime.utc(2026, 1, 31, 10, 30));
expect(ChartData.parseTime('31.01.2026'), isNull);
expect(ChartData.parseTime('abc'), isNull);
expect(ChartData.parseTime('2026-13-45'), isNull);
});

test('a column is a time column when every non-empty cell is a time', () {
expect(ChartData.isTimeColumn([['2026-01-01'], ['2026-01-02'], ['']], 0),
isTrue);
expect(ChartData.isTimeColumn([['2026-01-01'], ['x']], 0), isFalse);
expect(ChartData.isTimeColumn([['']], 0), isFalse);
});

test('dates place points at their real distance', () {
final series = ChartData.build(
rows: [
['2026-01-01', '1'],
['2026-01-02', '2'],
['2026-03-01', '3'],
],
labelColumn: 0,
valueColumn: 1,
type: QuickChartType.line,
);
final offsets = ChartData.timeOffsets(series.points)!;
// 1 January to 1 March is 59 days; 2 January to 1 March is 58.
expect(offsets, [0, 1, 59]);
// The gap before the third point is 58 times the first gap.
expect((offsets[2] - offsets[1]) / (offsets[1] - offsets[0]), 58);
});

test('date points come back in time order even when rows are not', () {
final series = ChartData.build(
rows: [
['2026-03-01', '3'],
['2026-01-01', '1'],
['2026-01-02', '2'],
],
labelColumn: 0,
valueColumn: 1,
type: QuickChartType.line,
);
expect(series.points.map((p) => p.label),
['2026-01-01', '2026-01-02', '2026-03-01']);
expect(series.points.first.time, DateTime.utc(2026, 1, 1));
});

test('a non-date X keeps row order and has no time offsets', () {
final series = ChartData.build(
rows: [
['b', '2'],
['a', '1'],
],
labelColumn: 0,
valueColumn: 1,
type: QuickChartType.line,
);
expect(series.points.map((p) => p.label), ['b', 'a']);
expect(ChartData.timeOffsets(series.points), isNull);
});

test('tick labels follow the range: hours, days, then months', () {
final t = DateTime.utc(2026, 1, 5, 9, 7);
expect(ChartData.timeTickLabel(t, const Duration(hours: 6)), '09:07');
expect(ChartData.timeTickLabel(t, const Duration(days: 30)),
'2026-01-05');
expect(ChartData.timeTickLabel(t, const Duration(days: 900)), '2026-01');
});

test('the SVG export places date points by time and labels dates', () {
final series = ChartData.build(
rows: [
['2026-01-01', '1'],
['2026-01-02', '2'],
['2026-03-01', '3'],
],
labelColumn: 0,
valueColumn: 1,
type: QuickChartType.line,
);
final svg = ChartSvg.build(
points: series.points,
type: QuickChartType.line,
colors: const ChartSvgColors(
background: '#ffffff',
text: '#000000',
grid: '#dddddd',
series: ['#1f77b4'],
),
);
expect(svg, contains('2026-03-01'));
expect(svg, contains('2026-01-01'));
});
});
}
Loading