diff --git a/lib/features/results/charts/chart_svg.dart b/lib/features/results/charts/chart_svg.dart new file mode 100644 index 00000000..e61c2572 --- /dev/null +++ b/lib/features/results/charts/chart_svg.dart @@ -0,0 +1,181 @@ +import 'dart:math' as math; + +import 'package:querya_desktop/features/results/charts/chart_data.dart'; + +/// Colors used by [ChartSvg], as `#rrggbb` strings. +class ChartSvgColors { + const ChartSvgColors({ + required this.background, + required this.text, + required this.grid, + required this.series, + }); + + final String background; + final String text; + final String grid; + + /// Series / slice colors; reused cyclically. The first one draws bars and lines. + final List series; + + /// `#rrggbb` for a 0xAARRGGBB color value (alpha is dropped). + static String hex(int argb) => + '#${(argb & 0xFFFFFF).toRadixString(16).padLeft(6, '0')}'; +} + +/// Renders a chart as a standalone SVG document, without touching Flutter. +class ChartSvg { + ChartSvg._(); + + static const double width = 960; + static const double height = 540; + static const double _left = 64; + static const double _right = 24; + static const double _top = 24; + static const double _bottom = 56; + + static String escape(String s) => s + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); + + 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, + }) { + final b = StringBuffer() + ..writeln('') + ..writeln(''); + if (title != null) b.writeln('${escape(title)}'); + b.writeln(''); + if (points.isNotEmpty) { + switch (type) { + case QuickChartType.bar: + _axes(b, points, colors, bars: true); + case QuickChartType.line: + _axes(b, points, colors, bars: false); + case QuickChartType.pie: + _pie(b, points, colors); + } + } + b.writeln(''); + return b.toString(); + } + + static void _axes( + StringBuffer b, + List points, + ChartSvgColors colors, { + required bool bars, + }) { + const plotW = width - _left - _right; + const 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; + double y(double v) => _top + plotH - (v - lo) / (hi - lo) * plotH; + + const ticks = 5; + for (var i = 0; i <= ticks; i++) { + final v = lo + (hi - lo) * i / ticks; + final py = y(v); + b + ..writeln('') + ..writeln('${escape(_tick(v))}'); + } + + final slot = plotW / points.length; + final step = (points.length / 8).ceil().clamp(1, 1000); + final fill = colors.series.first; + final zero = y(0); + final line = []; + for (var i = 0; i < points.length; i++) { + final cx = _left + slot * (i + 0.5); + final py = y(points[i].value); + if (bars) { + final w = slot * 0.7; + final top = math.min(py, zero); + b.writeln('${escape(points[i].label)}: ' + '${escape(_tick(points[i].value))}'); + } else { + line.add('${_n(cx)},${_n(py)}'); + } + if (i % step == 0) { + b.writeln('' + '${escape(points[i].label)}'); + } + } + if (!bars) { + b.writeln(''); + if (points.length <= 50) { + for (var i = 0; i < points.length; i++) { + final cx = _left + slot * (i + 0.5); + b.writeln('${escape(points[i].label)}: ' + '${escape(_tick(points[i].value))}'); + } + } + } + } + + static void _pie( + StringBuffer b, List points, ChartSvgColors colors) { + final total = points.fold(0, (s, p) => s + p.value); + if (total <= 0) return; + const cx = 300.0; + const cy = height / 2; + const r = 200.0; + var angle = -math.pi / 2; + for (var i = 0; i < points.length; i++) { + 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))}'; + if (points.length == 1) { + b.writeln('$tip'); + } else { + final x1 = cx + r * math.cos(angle); + final y1 = cy + r * math.sin(angle); + final x2 = cx + r * math.cos(angle + sweep); + final y2 = cy + r * math.sin(angle + sweep); + final large = sweep > math.pi ? 1 : 0; + b.writeln('$tip'); + } + angle += sweep; + } + // Legend. + final shown = math.min(points.length, 20); + for (var i = 0; i < shown; i++) { + final ly = 40.0 + i * 22; + final fill = colors.series[i % colors.series.length]; + final pct = (points[i].value / total * 100).toStringAsFixed(1); + b + ..writeln('') + ..writeln('' + '${escape(points[i].label)} ($pct%)'); + } + } +} diff --git a/lib/features/results/charts/quick_chart_view.dart b/lib/features/results/charts/quick_chart_view.dart index 816dfb52..4723f654 100644 --- a/lib/features/results/charts/quick_chart_view.dart +++ b/lib/features/results/charts/quick_chart_view.dart @@ -1,3 +1,4 @@ +import 'dart:convert' show utf8; import 'dart:typed_data'; import 'dart:ui' as ui; @@ -7,6 +8,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/rendering.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:querya_desktop/features/results/charts/chart_data.dart'; +import 'package:querya_desktop/features/results/charts/chart_svg.dart'; import 'package:querya_desktop/shared/widgets/querya_dropdown.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -25,6 +27,22 @@ Future _defaultPngSaver(Uint8List png) async { .saveTo(location.path); } +/// Saves rendered chart SVG text; replaceable in tests. +typedef ChartSvgSaver = Future Function(String svg); + +Future _defaultSvgSaver(String svg) async { + final location = await getSaveLocation( + acceptedTypeGroups: const [ + XTypeGroup(label: 'SVG', extensions: ['svg']), + ], + suggestedName: 'chart.svg', + ); + if (location == null) return; + await XFile.fromData(Uint8List.fromList(utf8.encode(svg)), + mimeType: 'image/svg+xml', name: 'chart.svg') + .saveTo(location.path); +} + /// 1-click chart tab: pick X / Y columns and a chart type, export as PNG. class QuickChartView extends material.StatefulWidget { const QuickChartView({ @@ -32,11 +50,13 @@ class QuickChartView extends material.StatefulWidget { required this.columns, required this.rows, this.onSavePng, + this.onSaveSvg, }); final List columns; final List> rows; final ChartPngSaver? onSavePng; + final ChartSvgSaver? onSaveSvg; @override material.State createState() => _QuickChartViewState(); @@ -99,6 +119,39 @@ class _QuickChartViewState extends material.State { await (widget.onSavePng ?? _defaultPngSaver)(data.buffer.asUint8List()); } + Future _exportSvg() async { + final valueCol = _valueCol; + if (valueCol == null) return; + final palette = context.semanticPalette; + final wb = context.workbench; + final svg = ChartSvg.build( + points: ChartData.build( + rows: widget.rows, + labelColumn: _labelCol, + valueColumn: valueCol, + type: _type, + ), + type: _type, + title: widget.columns[valueCol], + colors: ChartSvgColors( + background: ChartSvgColors.hex(wb.surface.toARGB32()), + text: ChartSvgColors.hex(wb.mutedForeground.toARGB32()), + grid: ChartSvgColors.hex(wb.borderSubtle.toARGB32()), + series: [ + for (final c in [ + palette.type1, + palette.type2, + palette.type3, + palette.type4, + palette.type5, + ]) + ChartSvgColors.hex(c.toARGB32()), + ], + ), + ); + await (widget.onSaveSvg ?? _defaultSvgSaver)(svg); + } + @override material.Widget build(material.BuildContext context) { final wb = context.workbench; @@ -171,6 +224,12 @@ class _QuickChartViewState extends material.State { icon: const material.Icon(material.Icons.image_outlined), onPressed: _export, ), + material.IconButton( + key: const material.ValueKey('chart_export_svg'), + tooltip: 'Export SVG', + icon: const material.Icon(material.Icons.polyline_outlined), + onPressed: _exportSvg, + ), ], ), ), diff --git a/test/features/results/charts/chart_svg_test.dart b/test/features/results/charts/chart_svg_test.dart new file mode 100644 index 00000000..293c3688 --- /dev/null +++ b/test/features/results/charts/chart_svg_test.dart @@ -0,0 +1,91 @@ +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() { + const colors = ChartSvgColors( + background: '#101010', + text: '#aaaaaa', + grid: '#333333', + series: ['#ff0000', '#00ff00'], + ); + const points = [ + ChartPoint('a', 1), + ChartPoint('b', 3), + ChartPoint('c', 2), + ]; + + String build(QuickChartType t, [List p = points, String? title]) => + ChartSvg.build(points: p, type: t, colors: colors, title: title); + + test('hex drops alpha and pads', () { + expect(ChartSvgColors.hex(0xFF0A0B0C), '#0a0b0c'); + expect(ChartSvgColors.hex(0xFF000001), '#000001'); + }); + + test('bar chart draws one rect per point plus the background', () { + final svg = build(QuickChartType.bar); + expect(svg, startsWith('b: 3')); + expect(svg.trimRight(), endsWith('')); + }); + + test('line chart draws a polyline and a dot per point', () { + final svg = build(QuickChartType.line); + expect(svg, contains('', 1)], + 'xx<y')); + expect(svg, isNot(contains('down: -2')); + expect('')); + }); + + test('all-equal values do not divide by zero', () { + final svg = build(QuickChartType.line, const [ + ChartPoint('a', 0), + ChartPoint('b', 0), + ]); + expect(svg, isNot(contains('NaN'))); + expect(svg, isNot(contains('Infinity'))); + }); +} diff --git a/test/features/results/charts/quick_chart_view_test.dart b/test/features/results/charts/quick_chart_view_test.dart index 99d981d7..910415d8 100644 --- a/test/features/results/charts/quick_chart_view_test.dart +++ b/test/features/results/charts/quick_chart_view_test.dart @@ -58,4 +58,29 @@ void main() { expect(saved, isNotNull); expect(saved!.isNotEmpty, isTrue); }); + + testWidgets('export SVG button saves an SVG for the current chart type', + (t) async { + String? saved; + await t.binding.setSurfaceSize(const Size(1000, 700)); + await t.pumpWidget(queryaThemeTestShell(child: QuickChartView( + columns: columns, + rows: rows, + onSaveSvg: (svg) async => saved = svg, + ))); + await t.pump(); + + await t.tap(find.byKey(const material.ValueKey('chart_export_svg'))); + await t.pump(); + expect(saved, isNotNull); + expect(saved, contains('amount')); + expect('