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
181 changes: 181 additions & 0 deletions lib/features/results/charts/chart_svg.dart
Original file line number Diff line number Diff line change
@@ -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<String> 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');

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,
}) {
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">');
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);
case QuickChartType.line:
_axes(b, points, colors, bars: false);
case QuickChartType.pie:
_pie(b, points, colors);
}
}
b.writeln('</svg>');
return b.toString();
}

static void _axes(
StringBuffer b,
List<ChartPoint> 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('<line x1="${_n(_left)}" y1="${_n(py)}" '
'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>');
}

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 = <String>[];
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('<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>');
} else {
line.add('${_n(cx)},${_n(py)}');
}
if (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 (!bars) {
b.writeln('<polyline points="${line.join(' ')}" fill="none" '
'stroke="$fill" stroke-width="2"/>');
if (points.length <= 50) {
for (var i = 0; i < points.length; i++) {
final cx = _left + slot * (i + 0.5);
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>');
}
}
}
}

static void _pie(
StringBuffer b, List<ChartPoint> points, ChartSvgColors colors) {
final total = points.fold<double>(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 = '<title>${escape(points[i].label)}: '
'${escape(_tick(points[i].value))}</title>';
if (points.length == 1) {
b.writeln('<circle cx="$cx" cy="$cy" r="$r" fill="$fill">$tip</circle>');
} 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('<path d="M $cx $cy L ${_n(x1)} ${_n(y1)} '
'A $r $r 0 $large 1 ${_n(x2)} ${_n(y2)} Z" fill="$fill" '
'stroke="${colors.background}" stroke-width="1">$tip</path>');
}
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('<rect x="560" y="${_n(ly - 10)}" width="12" height="12" '
'fill="$fill"/>')
..writeln('<text x="580" y="${_n(ly)}" fill="${colors.text}">'
'${escape(points[i].label)} ($pct%)</text>');
}
}
}
59 changes: 59 additions & 0 deletions lib/features/results/charts/quick_chart_view.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:convert' show utf8;
import 'dart:typed_data';
import 'dart:ui' as ui;

Expand All @@ -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';

Expand All @@ -25,18 +27,36 @@ Future<void> _defaultPngSaver(Uint8List png) async {
.saveTo(location.path);
}

/// Saves rendered chart SVG text; replaceable in tests.
typedef ChartSvgSaver = Future<void> Function(String svg);

Future<void> _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({
super.key,
required this.columns,
required this.rows,
this.onSavePng,
this.onSaveSvg,
});

final List<String> columns;
final List<List<String>> rows;
final ChartPngSaver? onSavePng;
final ChartSvgSaver? onSaveSvg;

@override
material.State<QuickChartView> createState() => _QuickChartViewState();
Expand Down Expand Up @@ -99,6 +119,39 @@ class _QuickChartViewState extends material.State<QuickChartView> {
await (widget.onSavePng ?? _defaultPngSaver)(data.buffer.asUint8List());
}

Future<void> _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;
Expand Down Expand Up @@ -171,6 +224,12 @@ class _QuickChartViewState extends material.State<QuickChartView> {
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,
),
],
),
),
Expand Down
91 changes: 91 additions & 0 deletions test/features/results/charts/chart_svg_test.dart
Original file line number Diff line number Diff line change
@@ -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<ChartPoint> 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('<?xml'));
expect(svg, contains('xmlns="http://www.w3.org/2000/svg"'));
expect('<rect '.allMatches(svg).length, 1 + points.length);
expect(svg, contains('fill="#101010"'));
expect(svg, contains('fill="#ff0000"'));
expect(svg, contains('<title>b: 3</title>'));
expect(svg.trimRight(), endsWith('</svg>'));
});

test('line chart draws a polyline and a dot per point', () {
final svg = build(QuickChartType.line);
expect(svg, contains('<polyline'));
expect('<circle '.allMatches(svg).length, points.length);
expect(svg, isNot(contains('<path')));
});

test('pie chart draws a slice per point and a percentage legend', () {
final svg = build(QuickChartType.pie);
expect('<path '.allMatches(svg).length, points.length);
expect(svg, contains('a (16.7%)'));
expect(svg, contains('b (50.0%)'));
});

test('a single slice is a full circle', () {
final svg = build(QuickChartType.pie, const [ChartPoint('only', 5)]);
expect(svg, contains('<circle'));
expect(svg, isNot(contains('<path')));
});

test('labels and title are XML-escaped', () {
final svg = build(
QuickChartType.bar,
const [ChartPoint('<a&"b">', 1)],
'x<y',
);
expect(svg, contains('&lt;a&amp;&quot;b&quot;&gt;'));
expect(svg, contains('<title>x&lt;y</title>'));
expect(svg, isNot(contains('<a&')));
});

test('negative values hang below the zero line', () {
final svg = build(QuickChartType.bar, const [
ChartPoint('up', 2),
ChartPoint('down', -2),
]);
expect(svg, contains('<title>down: -2</title>'));
expect('<rect '.allMatches(svg).length, 3);
});

test('empty data still yields a valid document', () {
final svg = build(QuickChartType.bar, const []);
expect('<rect '.allMatches(svg).length, 1);
expect(svg.trimRight(), endsWith('</svg>'));
});

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')));
});
}
Loading
Loading