Improve chart rendering and resolve theme logo paths
Charts: - Shrink axis label fonts and thin/space x-axis labels by actual pixel spacing so dense or long labels no longer overlap. - Line tooltip shows only the point nearest the cursor instead of every series stacked vertically. - Hovering a legend entry highlights its element: bar/line series fade the others (pie expands the matching slice), in app and presentation mode. - Add optional min/max threshold lines per bar/line chart (ignored for pie), editable in the chart editor and drawn in both the live preview and the exported SVG. Theme: - Resolve relative logo paths in a ThemeProfile against the project path and home directory so deck logos load regardless of working directory. Tests cover bound round-trip, editor fields, SVG bounds, legend-hover fading, and bound-line rendering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2d8be6f0dd
commit
67408c213c
18 changed files with 2372 additions and 443 deletions
|
|
@ -1,59 +0,0 @@
|
|||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "dkcamera",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKCamera",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkimagepickercontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKImagePickerController",
|
||||
"state" : {
|
||||
"branch" : "4.3.9",
|
||||
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkphotogallery",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sdwebimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImage",
|
||||
"state" : {
|
||||
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
|
||||
"version" : "5.21.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftygif",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kirualex/SwiftyGif.git",
|
||||
"state" : {
|
||||
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
|
||||
"version" : "5.4.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "tocropviewcontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/TimOliver/TOCropViewController",
|
||||
"state" : {
|
||||
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
|
||||
"version" : "2.8.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "dkcamera",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKCamera",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkimagepickercontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKImagePickerController",
|
||||
"state" : {
|
||||
"branch" : "4.3.9",
|
||||
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkphotogallery",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sdwebimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImage",
|
||||
"state" : {
|
||||
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
|
||||
"version" : "5.21.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftygif",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kirualex/SwiftyGif.git",
|
||||
"state" : {
|
||||
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
|
||||
"version" : "5.4.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "tocropviewcontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/TimOliver/TOCropViewController",
|
||||
"state" : {
|
||||
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
|
||||
"version" : "2.8.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
|
|
@ -2326,6 +2326,7 @@ const _dutchSourceStrings = {
|
|||
|
||||
const _dutchSourceStringAdditions = {
|
||||
'en': {
|
||||
'Annuleren': 'Cancel',
|
||||
'Afbeelding': 'Image',
|
||||
'Broncode': 'Source code',
|
||||
'Bullet': 'Bullet',
|
||||
|
|
@ -2354,6 +2355,15 @@ const _dutchSourceStringAdditions = {
|
|||
'Label': 'Label',
|
||||
'Rij': 'Row',
|
||||
'Reeks': 'Series',
|
||||
'Kleur van reeks': 'Series color',
|
||||
'Kleur van rij': 'Row color',
|
||||
'Hexkleur': 'Hex color',
|
||||
'Sorteren': 'Sort',
|
||||
'Oplopend sorteren': 'Sort ascending',
|
||||
'Aflopend sorteren': 'Sort descending',
|
||||
'Toepassen': 'Apply',
|
||||
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.':
|
||||
'Pie charts show at most the first two series; the labels form the slices.',
|
||||
'Platte tekst': 'Plain text',
|
||||
'Titel (optioneel)': 'Title (optional)',
|
||||
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.':
|
||||
|
|
@ -2371,6 +2381,15 @@ const _dutchSourceStringAdditions = {
|
|||
'voorbereiden…': 'preparing…',
|
||||
},
|
||||
'it': {
|
||||
'Annuleren': 'Annulla',
|
||||
'Kleur van reeks': 'Colore della serie',
|
||||
'Kleur van rij': 'Colore della riga',
|
||||
'Hexkleur': 'Colore esadecimale',
|
||||
'Sorteren': 'Ordina',
|
||||
'Oplopend sorteren': 'Ordina in modo crescente',
|
||||
'Aflopend sorteren': 'Ordina in modo decrescente',
|
||||
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.':
|
||||
'I grafici a torta mostrano al massimo le prime due serie; le etichette formano i segmenti.',
|
||||
'# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
|
||||
'1 slide geïmporteerd.': '1 slide imported.',
|
||||
'1 slide kopiëren naar…': 'Copy 1 slide to…',
|
||||
|
|
@ -2570,6 +2589,15 @@ const _dutchSourceStringAdditions = {
|
|||
'↑↓←→ navigate · Enter chooses · Double-click selects',
|
||||
},
|
||||
'de': {
|
||||
'Annuleren': 'Abbrechen',
|
||||
'Kleur van reeks': 'Reihenfarbe',
|
||||
'Kleur van rij': 'Zeilenfarbe',
|
||||
'Hexkleur': 'Hex-Farbe',
|
||||
'Sorteren': 'Sortieren',
|
||||
'Oplopend sorteren': 'Aufsteigend sortieren',
|
||||
'Aflopend sorteren': 'Absteigend sortieren',
|
||||
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.':
|
||||
'Kreisdiagramme zeigen höchstens die ersten zwei Reihen; die Beschriftungen bilden die Segmente.',
|
||||
'# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
|
||||
'1 slide geïmporteerd.': '1 slide imported.',
|
||||
'1 slide kopiëren naar…': 'Copy 1 slide to…',
|
||||
|
|
@ -2770,6 +2798,15 @@ const _dutchSourceStringAdditions = {
|
|||
'↑↓←→ navigate · Enter chooses · Double-click selects',
|
||||
},
|
||||
'fr': {
|
||||
'Annuleren': 'Annuler',
|
||||
'Kleur van reeks': 'Couleur de la série',
|
||||
'Kleur van rij': 'Couleur de la ligne',
|
||||
'Hexkleur': 'Couleur hexadécimale',
|
||||
'Sorteren': 'Trier',
|
||||
'Oplopend sorteren': 'Trier par ordre croissant',
|
||||
'Aflopend sorteren': 'Trier par ordre décroissant',
|
||||
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.':
|
||||
'Les graphiques en secteurs affichent au maximum les deux premières séries ; les libellés forment les segments.',
|
||||
'# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
|
||||
'1 slide geïmporteerd.': '1 slide imported.',
|
||||
'1 slide kopiëren naar…': 'Copy 1 slide to…',
|
||||
|
|
@ -2970,6 +3007,15 @@ const _dutchSourceStringAdditions = {
|
|||
'↑↓←→ navigate · Enter chooses · Double-click selects',
|
||||
},
|
||||
'es': {
|
||||
'Annuleren': 'Cancelar',
|
||||
'Kleur van reeks': 'Color de la serie',
|
||||
'Kleur van rij': 'Color de la fila',
|
||||
'Hexkleur': 'Color hexadecimal',
|
||||
'Sorteren': 'Ordenar',
|
||||
'Oplopend sorteren': 'Ordenar ascendente',
|
||||
'Aflopend sorteren': 'Ordenar descendente',
|
||||
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.':
|
||||
'Los gráficos circulares muestran como máximo las dos primeras series; las etiquetas forman los segmentos.',
|
||||
'# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
|
||||
'1 slide geïmporteerd.': '1 slide imported.',
|
||||
'1 slide kopiëren naar…': 'Copy 1 slide to…',
|
||||
|
|
@ -3170,6 +3216,15 @@ const _dutchSourceStringAdditions = {
|
|||
'↑↓←→ navigate · Enter chooses · Double-click selects',
|
||||
},
|
||||
'fy': {
|
||||
'Annuleren': 'Annulearje',
|
||||
'Kleur van reeks': 'Rigekleur',
|
||||
'Kleur van rij': 'Rijekleur',
|
||||
'Hexkleur': 'Hekskleur',
|
||||
'Sorteren': 'Sortearje',
|
||||
'Oplopend sorteren': 'Oprinnend sortearje',
|
||||
'Aflopend sorteren': 'Ôfrinnend sortearje',
|
||||
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.':
|
||||
'Sirkeldiagrammen litte maksimaal de earste twa rigen sjen; de labels foarmje de segminten.',
|
||||
'# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
|
||||
'1 slide geïmporteerd.': '1 slide imported.',
|
||||
'1 slide kopiëren naar…': 'Copy 1 slide to…',
|
||||
|
|
@ -3367,6 +3422,15 @@ const _dutchSourceStringAdditions = {
|
|||
'↑↓←→ navigate · Enter chooses · Double-click selects',
|
||||
},
|
||||
'pap': {
|
||||
'Annuleren': 'Kanselá',
|
||||
'Kleur van reeks': 'Koló di serie',
|
||||
'Kleur van rij': 'Koló di liña',
|
||||
'Hexkleur': 'Koló hexadecimal',
|
||||
'Sorteren': 'Ordená',
|
||||
'Oplopend sorteren': 'Ordená subiendu',
|
||||
'Aflopend sorteren': 'Ordená bahando',
|
||||
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.':
|
||||
'Gráfikonan circular ta mustra máximo e promé dos serienan; e labelnan ta forma e segmentonan.',
|
||||
'# Slide\n\nInhoud hier...': '# Slide\n\nContent here...',
|
||||
'1 slide geïmporteerd.': '1 slide imported.',
|
||||
'1 slide kopiëren naar…': 'Copy 1 slide to…',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,19 @@ import 'dart:convert';
|
|||
/// data files stay tidily in one place — separate from images/media.
|
||||
const String chartDataDirName = 'data';
|
||||
|
||||
const List<String> chartColorPalette = [
|
||||
'#003399',
|
||||
'#FFCC00',
|
||||
'#2563EB',
|
||||
'#F59E0B',
|
||||
'#10B981',
|
||||
'#EF4444',
|
||||
'#8B5CF6',
|
||||
'#06B6D4',
|
||||
'#EC4899',
|
||||
'#84CC16',
|
||||
];
|
||||
|
||||
/// Supported chart kinds for a chart slide.
|
||||
enum ChartType { bar, line, pie }
|
||||
|
||||
|
|
@ -16,19 +29,44 @@ ChartType _chartTypeFromName(String? name) => ChartType.values.firstWhere(
|
|||
class ChartSeries {
|
||||
final String name;
|
||||
final List<double> data;
|
||||
const ChartSeries({required this.name, required this.data});
|
||||
final String? color;
|
||||
const ChartSeries({required this.name, required this.data, this.color});
|
||||
|
||||
Map<String, dynamic> toJson() => {'name': name, 'data': data};
|
||||
Map<String, dynamic> toJson({bool includeData = true}) => {
|
||||
'name': name,
|
||||
if (includeData) 'data': data,
|
||||
if (color != null) 'color': color,
|
||||
};
|
||||
|
||||
factory ChartSeries.fromJson(Map<String, dynamic> json) => ChartSeries(
|
||||
factory ChartSeries.fromJson(Map<String, dynamic> json) {
|
||||
final color = normalizeChartColor(json['color']?.toString());
|
||||
return ChartSeries(
|
||||
name: (json['name'] ?? '').toString(),
|
||||
color: color,
|
||||
data: [
|
||||
for (final v in (json['data'] as List? ?? const []))
|
||||
(v as num?)?.toDouble() ?? 0,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String? normalizeChartColor(String? value) {
|
||||
if (value == null) return null;
|
||||
final raw = value.trim().toUpperCase();
|
||||
final normalized = raw.startsWith('#') ? raw : '#$raw';
|
||||
return RegExp(r'^#[0-9A-F]{6}$').hasMatch(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
String chartSeriesColor(ChartSeries series, int index) =>
|
||||
normalizeChartColor(series.color) ??
|
||||
chartColorPalette[index % chartColorPalette.length];
|
||||
|
||||
String chartRowColor(ChartSpec spec, int index) => index < spec.rowColors.length
|
||||
? normalizeChartColor(spec.rowColors[index]) ??
|
||||
chartColorPalette[index % chartColorPalette.length]
|
||||
: chartColorPalette[index % chartColorPalette.length];
|
||||
|
||||
/// The full chart specification, stored as JSON inside a ```chart fenced block.
|
||||
///
|
||||
/// Small charts keep their data inline; data-driven charts instead point at an
|
||||
|
|
@ -40,31 +78,52 @@ class ChartSpec {
|
|||
final String title;
|
||||
final String? source;
|
||||
final List<String> x;
|
||||
final List<String?> rowColors;
|
||||
final List<ChartSeries> series;
|
||||
|
||||
/// Optional horizontal reference lines drawn across the plot so it is clear
|
||||
/// where a data point sits relative to a threshold. Only meaningful for bar
|
||||
/// and line charts (ignored for pie); either may be left null.
|
||||
final double? minBound;
|
||||
final double? maxBound;
|
||||
|
||||
const ChartSpec({
|
||||
this.type = ChartType.bar,
|
||||
this.title = '',
|
||||
this.source,
|
||||
this.x = const [],
|
||||
this.rowColors = const [],
|
||||
this.series = const [],
|
||||
this.minBound,
|
||||
this.maxBound,
|
||||
});
|
||||
|
||||
bool get hasInlineData => x.isNotEmpty && series.isNotEmpty;
|
||||
|
||||
/// Bounds only apply to bar/line charts; a pie spec never shows them.
|
||||
bool get supportsBounds => type != ChartType.pie;
|
||||
|
||||
ChartSpec copyWith({
|
||||
ChartType? type,
|
||||
String? title,
|
||||
String? source,
|
||||
bool clearSource = false,
|
||||
List<String>? x,
|
||||
List<String?>? rowColors,
|
||||
List<ChartSeries>? series,
|
||||
double? minBound,
|
||||
bool clearMinBound = false,
|
||||
double? maxBound,
|
||||
bool clearMaxBound = false,
|
||||
}) => ChartSpec(
|
||||
type: type ?? this.type,
|
||||
title: title ?? this.title,
|
||||
source: clearSource ? null : (source ?? this.source),
|
||||
x: x ?? this.x,
|
||||
rowColors: rowColors ?? this.rowColors,
|
||||
series: series ?? this.series,
|
||||
minBound: clearMinBound ? null : (minBound ?? this.minBound),
|
||||
maxBound: clearMaxBound ? null : (maxBound ?? this.maxBound),
|
||||
);
|
||||
|
||||
/// Parse the JSON content of a ```chart block. Tolerant: returns a default
|
||||
|
|
@ -78,7 +137,13 @@ class ChartSpec {
|
|||
type: _chartTypeFromName(data['type'] as String?),
|
||||
title: (data['title'] ?? '').toString(),
|
||||
source: (src == null || src.isEmpty) ? null : src,
|
||||
minBound: (data['minBound'] as num?)?.toDouble(),
|
||||
maxBound: (data['maxBound'] as num?)?.toDouble(),
|
||||
x: [for (final v in (data['x'] as List? ?? const [])) v.toString()],
|
||||
rowColors: [
|
||||
for (final value in (data['rowColors'] as List? ?? const []))
|
||||
normalizeChartColor(value?.toString()),
|
||||
],
|
||||
series: [
|
||||
for (final s in (data['series'] as List? ?? const []))
|
||||
ChartSeries.fromJson(Map<String, dynamic>.from(s as Map)),
|
||||
|
|
@ -96,12 +161,23 @@ class ChartSpec {
|
|||
final map = <String, dynamic>{'type': type.name};
|
||||
if (title.isNotEmpty) map['title'] = title;
|
||||
if (source != null) map['source'] = source;
|
||||
if (supportsBounds) {
|
||||
if (minBound != null) map['minBound'] = minBound;
|
||||
if (maxBound != null) map['maxBound'] = maxBound;
|
||||
}
|
||||
final dropData = forStorage && source != null;
|
||||
if (rowColors.any((color) => color != null)) {
|
||||
map['rowColors'] = rowColors;
|
||||
}
|
||||
if (!dropData) {
|
||||
if (x.isNotEmpty) map['x'] = x;
|
||||
if (series.isNotEmpty) {
|
||||
map['series'] = [for (final s in series) s.toJson()];
|
||||
}
|
||||
} else if (series.any((series) => series.color != null)) {
|
||||
map['series'] = [
|
||||
for (final series in series) series.toJson(includeData: false),
|
||||
];
|
||||
}
|
||||
return const JsonEncoder.withIndent(' ').convert(map);
|
||||
}
|
||||
|
|
@ -109,7 +185,28 @@ class ChartSpec {
|
|||
/// Return a copy with x/series taken from [csv]; keeps [source].
|
||||
ChartSpec withCsv(String csv) {
|
||||
final parsed = parseCsv(csv);
|
||||
return copyWith(x: parsed.$1, series: parsed.$2);
|
||||
final colorsByLabel = x.isEmpty
|
||||
? const <String, String?>{}
|
||||
: <String, String?>{
|
||||
for (var i = 0; i < x.length; i++)
|
||||
x[i]: i < rowColors.length ? rowColors[i] : null,
|
||||
};
|
||||
return copyWith(
|
||||
x: parsed.$1,
|
||||
rowColors: [
|
||||
for (var i = 0; i < parsed.$1.length; i++)
|
||||
colorsByLabel[parsed.$1[i]] ??
|
||||
(i < rowColors.length ? rowColors[i] : null),
|
||||
],
|
||||
series: [
|
||||
for (var i = 0; i < parsed.$2.length; i++)
|
||||
ChartSeries(
|
||||
name: parsed.$2[i].name,
|
||||
data: parsed.$2[i].data,
|
||||
color: i < series.length ? series[i].color : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class FileService {
|
|||
final ImageService _img;
|
||||
final ThemeProfile Function() _themeProfile;
|
||||
final String Function() _languageCode;
|
||||
final String? Function() _homeDirectory;
|
||||
final CaptionService _captions = CaptionService();
|
||||
|
||||
FileService(
|
||||
|
|
@ -51,9 +52,30 @@ class FileService {
|
|||
this._img,
|
||||
this._themeProfile, {
|
||||
String Function()? languageCode,
|
||||
}) : _languageCode = languageCode ?? (() => 'nl');
|
||||
String? Function()? homeDirectory,
|
||||
}) : _languageCode = languageCode ?? (() => 'nl'),
|
||||
_homeDirectory = homeDirectory ?? (() => null);
|
||||
|
||||
ThemeProfile get currentThemeProfile => _themeProfile();
|
||||
ThemeProfile get currentThemeProfile => resolveThemeProfile(_themeProfile());
|
||||
|
||||
ThemeProfile resolveThemeProfile(
|
||||
ThemeProfile profile, {
|
||||
String? projectPath,
|
||||
}) {
|
||||
final logoPath = profile.logoPath;
|
||||
if (logoPath == null || logoPath.trim().isEmpty || p.isAbsolute(logoPath)) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
final bases = [?projectPath, ?_homeDirectory()];
|
||||
for (final base in bases) {
|
||||
final candidate = p.normalize(p.join(base, logoPath));
|
||||
if (File(candidate).existsSync()) {
|
||||
return profile.copyWith(logoPath: candidate);
|
||||
}
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
String _d(String text) => AppLocalizations.sourceFor(_languageCode(), text);
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ class MarpHtmlService {
|
|||
for (final slide in marpSlides(deckMarkdown)) {
|
||||
sections
|
||||
..write('<section class="slide"><script type="text/markdown">')
|
||||
..write(_guard(renderChartBlocks(slide)))
|
||||
..write(_guard(renderChartBlocks(slide, theme: theme)))
|
||||
..write('</script></section>');
|
||||
}
|
||||
|
||||
|
|
@ -110,23 +110,12 @@ class MarpHtmlService {
|
|||
multiLine: true,
|
||||
);
|
||||
|
||||
static const List<String> _chartPalette = [
|
||||
'#2563EB',
|
||||
'#F59E0B',
|
||||
'#10B981',
|
||||
'#EF4444',
|
||||
'#8B5CF6',
|
||||
'#06B6D4',
|
||||
'#EC4899',
|
||||
'#84CC16',
|
||||
];
|
||||
|
||||
/// Replace ```chart fenced blocks with a self-contained inline SVG, so the
|
||||
/// exported HTML renders charts without any JS chart library.
|
||||
static String renderChartBlocks(String slideMarkdown) {
|
||||
static String renderChartBlocks(String slideMarkdown, {ThemeProfile? theme}) {
|
||||
return slideMarkdown.replaceAllMapped(_chartFence, (m) {
|
||||
final spec = ChartSpec.parse(m.group(1)!);
|
||||
return '\n<div class="chart">${_chartSvg(spec)}</div>\n';
|
||||
return '\n<div class="chart">${_chartSvg(spec, theme)}</div>\n';
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -135,52 +124,126 @@ class MarpHtmlService {
|
|||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
|
||||
static String _color(int i) => _chartPalette[i % _chartPalette.length];
|
||||
static String _color(ChartSpec spec, int i, ThemeProfile? theme) {
|
||||
final series = spec.series[i];
|
||||
if (series.color == null && i == 0 && theme != null) {
|
||||
return theme.accentColor;
|
||||
}
|
||||
return chartSeriesColor(series, i);
|
||||
}
|
||||
|
||||
static String _chartSvg(ChartSpec spec) {
|
||||
static String _chartSvg(ChartSpec spec, ThemeProfile? theme) {
|
||||
if (!spec.hasInlineData) {
|
||||
return '<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg"></svg>';
|
||||
}
|
||||
final textColor = theme?.textColor ?? '#111827';
|
||||
final titleBackground = theme?.titleBackgroundColor ?? '#F8FAFC';
|
||||
final titleColor = theme?.titleTextColor ?? textColor;
|
||||
final accent = theme?.accentColor ?? '#2563EB';
|
||||
final b = StringBuffer()
|
||||
..write(
|
||||
'<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg" '
|
||||
'font-family="inherit" width="100%">',
|
||||
);
|
||||
if (spec.title.isNotEmpty) {
|
||||
b.write(
|
||||
'<text x="400" y="34" text-anchor="middle" font-size="26" '
|
||||
'font-weight="bold" fill="#111">${_esc(spec.title)}</text>',
|
||||
);
|
||||
}
|
||||
// Legend (multi-series, non-pie).
|
||||
final top = spec.title.isNotEmpty ? 56.0 : 24.0;
|
||||
var plotTop = top;
|
||||
if (spec.type != ChartType.pie && spec.series.length > 1) {
|
||||
var lx = 60.0;
|
||||
for (var i = 0; i < spec.series.length; i++) {
|
||||
final title = spec.title.length > 52
|
||||
? '${spec.title.substring(0, 51)}…'
|
||||
: spec.title;
|
||||
b
|
||||
..write(
|
||||
'<rect x="$lx" y="${top + 2}" width="14" height="14" rx="3" fill="${_color(i)}"/>',
|
||||
'<rect x="38" y="12" width="724" height="44" rx="9" '
|
||||
'fill="$titleBackground"/>',
|
||||
)
|
||||
..write(
|
||||
'<text x="${lx + 20}" y="${top + 14}" font-size="16" fill="#333">${_esc(spec.series[i].name)}</text>',
|
||||
'<rect x="38" y="12" width="7" height="44" rx="3" fill="$accent"/>',
|
||||
)
|
||||
..write(
|
||||
'<text x="62" y="41" font-size="23" font-weight="bold" '
|
||||
'fill="$titleColor">${_esc(title)}</text>',
|
||||
);
|
||||
lx += 30 + spec.series[i].name.length * 9 + 24;
|
||||
}
|
||||
plotTop = top + 28;
|
||||
}
|
||||
final plotTop = spec.title.isNotEmpty ? 68.0 : 20.0;
|
||||
switch (spec.type) {
|
||||
case ChartType.bar:
|
||||
_barSvg(b, spec, plotTop);
|
||||
_barSvg(b, spec, plotTop, theme);
|
||||
case ChartType.line:
|
||||
_lineSvg(b, spec, plotTop);
|
||||
_lineSvg(b, spec, plotTop, theme);
|
||||
case ChartType.pie:
|
||||
_pieSvg(b, spec, plotTop);
|
||||
final legendRows = (spec.x.length / 6).ceil().clamp(1, 3);
|
||||
_pieSvg(b, spec, plotTop, theme, bottom: 398 - (legendRows - 1) * 28);
|
||||
}
|
||||
if (spec.type == ChartType.pie) {
|
||||
_pieLegendSvg(b, spec, textColor);
|
||||
} else {
|
||||
_legendSvg(b, spec, theme, textColor);
|
||||
}
|
||||
b.write('</svg>');
|
||||
return b.toString();
|
||||
}
|
||||
|
||||
static void _legendSvg(
|
||||
StringBuffer b,
|
||||
ChartSpec spec,
|
||||
ThemeProfile? theme,
|
||||
String textColor,
|
||||
) {
|
||||
final count = math.min(spec.series.length, 6);
|
||||
final cellWidth = 720.0 / count;
|
||||
for (var i = 0; i < count; i++) {
|
||||
final rawName = spec.series[i].name.isEmpty
|
||||
? 'Reeks ${i + 1}'
|
||||
: spec.series[i].name;
|
||||
final name = rawName.length > 12
|
||||
? '${rawName.substring(0, 11)}…'
|
||||
: rawName;
|
||||
final x = 40 + i * cellWidth;
|
||||
b
|
||||
..write(
|
||||
'<rect x="$x" y="414" width="${cellWidth - 8}" height="24" rx="12" '
|
||||
'fill="$textColor" fill-opacity=".05"/>',
|
||||
)
|
||||
..write(
|
||||
'<circle cx="${x + 13}" cy="426" r="5" '
|
||||
'fill="${_color(spec, i, theme)}"/>',
|
||||
)
|
||||
..write(
|
||||
'<text x="${x + 24}" y="431" font-size="13" font-weight="600" '
|
||||
'fill="$textColor">${_esc(name)}</text>',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static void _pieLegendSvg(StringBuffer b, ChartSpec spec, String textColor) {
|
||||
const maxColumns = 6;
|
||||
final columns = math.min(spec.x.length, maxColumns);
|
||||
final rows = (spec.x.length / maxColumns).ceil().clamp(1, 3);
|
||||
final cellWidth = 720.0 / columns;
|
||||
final startY = 414.0 - (rows - 1) * 28;
|
||||
for (var i = 0; i < spec.x.length; i++) {
|
||||
final row = i ~/ maxColumns;
|
||||
if (row >= rows) break;
|
||||
final column = i % maxColumns;
|
||||
final x = 40 + column * cellWidth;
|
||||
final y = startY + row * 28;
|
||||
final label = spec.x[i].length > 12
|
||||
? '${spec.x[i].substring(0, 11)}…'
|
||||
: spec.x[i];
|
||||
b
|
||||
..write(
|
||||
'<rect x="$x" y="$y" width="${cellWidth - 8}" height="24" rx="12" '
|
||||
'fill="$textColor" fill-opacity=".05"/>',
|
||||
)
|
||||
..write(
|
||||
'<circle cx="${x + 13}" cy="${y + 12}" r="5" '
|
||||
'fill="${chartRowColor(spec, i)}"/>',
|
||||
)
|
||||
..write(
|
||||
'<text x="${x + 24}" y="${y + 17}" font-size="13" font-weight="600" '
|
||||
'fill="$textColor">${_esc(label)}</text>',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static double _maxY(ChartSpec spec) {
|
||||
var m = 0.0;
|
||||
for (final s in spec.series) {
|
||||
|
|
@ -188,9 +251,44 @@ class MarpHtmlService {
|
|||
if (v > m) m = v;
|
||||
}
|
||||
}
|
||||
if (spec.supportsBounds) {
|
||||
for (final b in [spec.minBound, spec.maxBound]) {
|
||||
if (b != null && b > m) m = b;
|
||||
}
|
||||
}
|
||||
return m <= 0 ? 1 : m * 1.15;
|
||||
}
|
||||
|
||||
/// Draw the optional min/max threshold lines (bar/line only) as dashed rules.
|
||||
static void _boundLinesSvg(
|
||||
StringBuffer b,
|
||||
ChartSpec spec,
|
||||
double left,
|
||||
double top,
|
||||
double right,
|
||||
double bottom,
|
||||
double maxY,
|
||||
) {
|
||||
if (!spec.supportsBounds) return;
|
||||
void draw(double? value, String color, String prefix) {
|
||||
if (value == null || value < 0 || value > maxY) return;
|
||||
final y = bottom - (bottom - top) * (value / maxY);
|
||||
b
|
||||
..write(
|
||||
'<line x1="$left" y1="$y" x2="$right" y2="$y" stroke="$color" '
|
||||
'stroke-width="2.5" stroke-dasharray="8 5"/>',
|
||||
)
|
||||
..write(
|
||||
'<text x="${right - 4}" y="${y - 5}" text-anchor="end" '
|
||||
'font-size="14" font-weight="700" fill="$color">'
|
||||
'$prefix ${_num(value)}</text>',
|
||||
);
|
||||
}
|
||||
|
||||
draw(spec.minBound, '#F59E0B', 'min');
|
||||
draw(spec.maxBound, '#EF4444', 'max');
|
||||
}
|
||||
|
||||
static String _num(double v) =>
|
||||
v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(1);
|
||||
|
||||
|
|
@ -217,16 +315,27 @@ class MarpHtmlService {
|
|||
}
|
||||
// X labels.
|
||||
final n = spec.x.length;
|
||||
final step = math.max(1, (n / 8).ceil());
|
||||
for (var i = 0; i < n; i++) {
|
||||
if (i != n - 1 && i % step != 0) continue;
|
||||
final x = left + (right - left) * (i + 0.5) / n;
|
||||
final label = spec.x[i].length > 10
|
||||
? '${spec.x[i].substring(0, 9)}…'
|
||||
: spec.x[i];
|
||||
b.write(
|
||||
'<text x="$x" y="${bottom + 22}" text-anchor="middle" font-size="14" fill="#334155">${_esc(spec.x[i])}</text>',
|
||||
'<text x="$x" y="${bottom + 20}" text-anchor="middle" '
|
||||
'font-size="13" fill="#334155">${_esc(label)}</text>',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static void _barSvg(StringBuffer b, ChartSpec spec, double top) {
|
||||
const left = 60.0, right = 770.0, bottom = 400.0;
|
||||
static void _barSvg(
|
||||
StringBuffer b,
|
||||
ChartSpec spec,
|
||||
double top,
|
||||
ThemeProfile? theme,
|
||||
) {
|
||||
const left = 60.0, right = 770.0, bottom = 382.0;
|
||||
final maxY = _maxY(spec);
|
||||
_axes(b, spec, left, top, right, bottom, maxY);
|
||||
final n = spec.x.length;
|
||||
|
|
@ -241,14 +350,21 @@ class MarpHtmlService {
|
|||
final h = (bottom - top) * (v / maxY);
|
||||
final x = gx + barW * si;
|
||||
b.write(
|
||||
'<rect x="$x" y="${bottom - h}" width="${barW * 0.92}" height="$h" rx="2" fill="${_color(si)}"/>',
|
||||
'<rect x="$x" y="${bottom - h}" width="${barW * 0.86}" height="$h" '
|
||||
'rx="5" fill="${_color(spec, si, theme)}"/>',
|
||||
);
|
||||
}
|
||||
}
|
||||
_boundLinesSvg(b, spec, left, top, right, bottom, maxY);
|
||||
}
|
||||
|
||||
static void _lineSvg(StringBuffer b, ChartSpec spec, double top) {
|
||||
const left = 60.0, right = 770.0, bottom = 400.0;
|
||||
static void _lineSvg(
|
||||
StringBuffer b,
|
||||
ChartSpec spec,
|
||||
double top,
|
||||
ThemeProfile? theme,
|
||||
) {
|
||||
const left = 60.0, right = 770.0, bottom = 382.0;
|
||||
final maxY = _maxY(spec);
|
||||
_axes(b, spec, left, top, right, bottom, maxY);
|
||||
final n = spec.x.length;
|
||||
|
|
@ -260,48 +376,81 @@ class MarpHtmlService {
|
|||
for (var i = 0; i < data.length; i++) '${px(i)},${py(data[i])}',
|
||||
].join(' ');
|
||||
b.write(
|
||||
'<polyline points="$pts" fill="none" stroke="${_color(si)}" stroke-width="3"/>',
|
||||
'<polyline points="$pts" fill="none" '
|
||||
'stroke="${_color(spec, si, theme)}" stroke-width="4" '
|
||||
'stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
);
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
b.write(
|
||||
'<circle cx="${px(i)}" cy="${py(data[i])}" r="4" fill="${_color(si)}"/>',
|
||||
'<circle cx="${px(i)}" cy="${py(data[i])}" r="5" '
|
||||
'fill="${_color(spec, si, theme)}" stroke="white" stroke-width="2"/>',
|
||||
);
|
||||
}
|
||||
}
|
||||
_boundLinesSvg(b, spec, left, top, right, bottom, maxY);
|
||||
}
|
||||
|
||||
static void _pieSvg(StringBuffer b, ChartSpec spec, double top) {
|
||||
final series = spec.series.first;
|
||||
final total = series.data.fold<double>(0, (a, v) => a + v);
|
||||
const cx = 250.0, cy = 240.0, r = 150.0;
|
||||
var angle = -90.0; // start at top
|
||||
for (var i = 0; i < series.data.length; i++) {
|
||||
final frac = total > 0 ? series.data[i] / total : 0;
|
||||
static void _pieSvg(
|
||||
StringBuffer b,
|
||||
ChartSpec spec,
|
||||
double top,
|
||||
ThemeProfile? theme, {
|
||||
required double bottom,
|
||||
}) {
|
||||
final count = math.min(spec.series.length, 2);
|
||||
final columns = count;
|
||||
final rows = (count / columns).ceil();
|
||||
final cellWidth = 720.0 / columns;
|
||||
final cellHeight = (bottom - top) / rows;
|
||||
final radius = math.min(cellWidth * 0.25, cellHeight * 0.42);
|
||||
for (var xi = 0; xi < count; xi++) {
|
||||
final col = xi % columns;
|
||||
final row = xi ~/ columns;
|
||||
final cellLeft = 40 + cellWidth * col;
|
||||
final cx = cellLeft + cellWidth * 0.36;
|
||||
final cy = top + cellHeight * (row + 0.5);
|
||||
final series = spec.series[xi];
|
||||
final values = [
|
||||
for (var labelIndex = 0; labelIndex < spec.x.length; labelIndex++)
|
||||
labelIndex < series.data.length && series.data[labelIndex] > 0
|
||||
? series.data[labelIndex]
|
||||
: 0.0,
|
||||
];
|
||||
final total = values.fold<double>(0, (a, v) => a + v);
|
||||
var angle = -90.0;
|
||||
for (var labelIndex = 0; labelIndex < values.length; labelIndex++) {
|
||||
final frac = total > 0 ? values[labelIndex] / total : 0;
|
||||
final sweep = frac * 360;
|
||||
if (sweep <= 0) continue;
|
||||
final a0 = angle * math.pi / 180;
|
||||
final a1 = (angle + sweep) * math.pi / 180;
|
||||
final x0 = cx + r * math.cos(a0), y0 = cy + r * math.sin(a0);
|
||||
final x1 = cx + r * math.cos(a1), y1 = cy + r * math.sin(a1);
|
||||
final x0 = cx + radius * math.cos(a0);
|
||||
final y0 = cy + radius * math.sin(a0);
|
||||
final x1 = cx + radius * math.cos(a1);
|
||||
final y1 = cy + radius * math.sin(a1);
|
||||
final large = sweep > 180 ? 1 : 0;
|
||||
b.write(
|
||||
'<path d="M$cx,$cy L$x0,$y0 A$r,$r 0 $large,1 $x1,$y1 Z" fill="${_color(i)}"/>',
|
||||
'<path d="M$cx,$cy L$x0,$y0 A$radius,$radius 0 $large,1 '
|
||||
'$x1,$y1 Z" '
|
||||
'fill="${chartRowColor(spec, labelIndex)}" '
|
||||
'stroke="white" '
|
||||
'stroke-width="2"/>',
|
||||
);
|
||||
angle += sweep;
|
||||
}
|
||||
// Legend on the right.
|
||||
var ly = 120.0;
|
||||
for (var i = 0; i < spec.x.length && i < series.data.length; i++) {
|
||||
b
|
||||
..write('<circle cx="$cx" cy="$cy" r="${radius * 0.43}" fill="white"/>')
|
||||
..write(
|
||||
'<rect x="520" y="$ly" width="16" height="16" rx="3" fill="${_color(i)}"/>',
|
||||
)
|
||||
..write(
|
||||
'<text x="544" y="${ly + 13}" font-size="16" fill="#333">${_esc(spec.x[i])}</text>',
|
||||
'<text x="${cellLeft + cellWidth * 0.66}" y="${cy + 5}" '
|
||||
'font-size="14" font-weight="700">'
|
||||
'${_esc(_shortChartLabel(series.name.isEmpty ? 'Reeks ${xi + 1}' : series.name))}</text>',
|
||||
);
|
||||
ly += 28;
|
||||
}
|
||||
}
|
||||
|
||||
static String _shortChartLabel(String value) =>
|
||||
value.length > 13 ? '${value.substring(0, 12)}…' : value;
|
||||
|
||||
/// CSS that mirrors the deck's [ThemeProfile]: slide background, text and
|
||||
/// accent colours, table colours and font. The EB Garamond font is embedded
|
||||
/// (base64) so it renders offline; other fonts resolve to system families.
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ final fileServiceProvider = Provider<FileService>((ref) {
|
|||
ref.read(imageServiceProvider),
|
||||
() => ref.read(settingsProvider).themeProfile,
|
||||
languageCode: () => ref.read(settingsProvider).languageCode,
|
||||
homeDirectory: () => ref.read(settingsProvider).homeDirectory,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -128,8 +129,14 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
|
||||
/// Load a deck that was already parsed (used by the tab manager).
|
||||
void loadDeck(Deck deck, {String? filePath}) {
|
||||
final resolvedDeck = deck.copyWith(
|
||||
themeProfile: _file.resolveThemeProfile(
|
||||
deck.themeProfile,
|
||||
projectPath: deck.projectPath,
|
||||
),
|
||||
);
|
||||
_clearHistory();
|
||||
state = DeckState(deck: deck, filePath: filePath, isDirty: false);
|
||||
state = DeckState(deck: resolvedDeck, filePath: filePath, isDirty: false);
|
||||
}
|
||||
|
||||
Future<void> openDeck({String? initialDirectory}) async {
|
||||
|
|
@ -414,7 +421,14 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
void updateThemeProfile(ThemeProfile profile) {
|
||||
final deck = state.deck;
|
||||
if (deck == null) return;
|
||||
_mutate(deck.copyWith(themeProfile: profile));
|
||||
_mutate(
|
||||
deck.copyWith(
|
||||
themeProfile: _file.resolveThemeProfile(
|
||||
profile,
|
||||
projectPath: deck.projectPath,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Update the (separate) annotation layer. Kept out of the undo/redo history
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
|
@ -31,19 +32,23 @@ class ChartEditor extends StatefulWidget {
|
|||
|
||||
class _ChartEditorState extends State<ChartEditor> {
|
||||
late final TextEditingController _title;
|
||||
late final TextEditingController _minBound;
|
||||
late final TextEditingController _maxBound;
|
||||
late ChartType _type;
|
||||
String? _source;
|
||||
|
||||
// Editable grid model (strings while editing).
|
||||
List<String> _xLabels = [];
|
||||
List<String?> _rowColors = [];
|
||||
List<String> _seriesNames = [];
|
||||
List<String?> _seriesColors = [];
|
||||
List<List<String>> _values = []; // [row][col]
|
||||
|
||||
// Bumped on structural changes so cell fields rebuild with fresh values.
|
||||
int _rev = 0;
|
||||
|
||||
static const _labelW = 130.0;
|
||||
static const _cellW = 96.0;
|
||||
static const _minLabelW = 238.0;
|
||||
static const _minCellW = 150.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -53,13 +58,29 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
_source = spec.source;
|
||||
_title = TextEditingController(text: spec.title);
|
||||
_title.addListener(_emit);
|
||||
_minBound = TextEditingController(text: _fmtBound(spec.minBound));
|
||||
_maxBound = TextEditingController(text: _fmtBound(spec.maxBound));
|
||||
_minBound.addListener(_emit);
|
||||
_maxBound.addListener(_emit);
|
||||
_loadFromSpec(spec);
|
||||
}
|
||||
|
||||
static String _fmtBound(double? v) => v == null ? '' : _fmt(v);
|
||||
|
||||
static double? _parseBound(String raw) {
|
||||
final text = raw.trim().replaceAll(',', '.');
|
||||
return text.isEmpty ? null : double.tryParse(text);
|
||||
}
|
||||
|
||||
void _loadFromSpec(ChartSpec spec) {
|
||||
if (spec.hasInlineData) {
|
||||
_seriesNames = [for (final s in spec.series) s.name];
|
||||
_seriesColors = [for (final s in spec.series) s.color];
|
||||
_xLabels = List<String>.from(spec.x);
|
||||
_rowColors = [
|
||||
for (var i = 0; i < spec.x.length; i++)
|
||||
i < spec.rowColors.length ? spec.rowColors[i] : null,
|
||||
];
|
||||
_values = [
|
||||
for (var r = 0; r < spec.x.length; r++)
|
||||
[
|
||||
|
|
@ -70,7 +91,9 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
} else {
|
||||
// Sensible empty starting grid.
|
||||
_seriesNames = ['Reeks 1'];
|
||||
_seriesColors = [null];
|
||||
_xLabels = ['', '', ''];
|
||||
_rowColors = [null, null, null];
|
||||
_values = List.generate(3, (_) => ['']);
|
||||
}
|
||||
}
|
||||
|
|
@ -81,6 +104,8 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
_minBound.dispose();
|
||||
_maxBound.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -89,6 +114,7 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
for (var c = 0; c < _seriesNames.length; c++)
|
||||
ChartSeries(
|
||||
name: _seriesNames[c],
|
||||
color: _seriesColors[c],
|
||||
data: [
|
||||
for (var r = 0; r < _values.length; r++)
|
||||
double.tryParse(
|
||||
|
|
@ -105,7 +131,10 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
title: _title.text,
|
||||
source: _source,
|
||||
x: List<String>.from(_xLabels),
|
||||
rowColors: List<String?>.from(_rowColors),
|
||||
series: series,
|
||||
minBound: _type == ChartType.pie ? null : _parseBound(_minBound.text),
|
||||
maxBound: _type == ChartType.pie ? null : _parseBound(_maxBound.text),
|
||||
);
|
||||
widget.onUpdate(widget.slide.copyWith(customMarkdown: spec.toBlock()));
|
||||
}
|
||||
|
|
@ -114,6 +143,7 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
|
||||
void _addColumn() {
|
||||
_seriesNames.add('Reeks ${_seriesNames.length + 1}');
|
||||
_seriesColors.add(null);
|
||||
for (final row in _values) {
|
||||
row.add('');
|
||||
}
|
||||
|
|
@ -124,6 +154,7 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
void _removeColumn(int c) {
|
||||
if (_seriesNames.length <= 1) return;
|
||||
_seriesNames.removeAt(c);
|
||||
_seriesColors.removeAt(c);
|
||||
for (final row in _values) {
|
||||
if (c < row.length) row.removeAt(c);
|
||||
}
|
||||
|
|
@ -133,6 +164,7 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
|
||||
void _addRow() {
|
||||
_xLabels.add('');
|
||||
_rowColors.add(null);
|
||||
_values.add(List<String>.filled(_seriesNames.length, '', growable: true));
|
||||
_bump();
|
||||
_emit();
|
||||
|
|
@ -141,6 +173,7 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
void _removeRow(int r) {
|
||||
if (_xLabels.length <= 1) return;
|
||||
_xLabels.removeAt(r);
|
||||
_rowColors.removeAt(r);
|
||||
_values.removeAt(r);
|
||||
_bump();
|
||||
_emit();
|
||||
|
|
@ -199,12 +232,26 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
setState(() {
|
||||
_source = source;
|
||||
_xLabels = parsed.$1.isEmpty ? [''] : parsed.$1;
|
||||
_rowColors = [
|
||||
for (var i = 0; i < _xLabels.length; i++)
|
||||
i < _rowColors.length ? _rowColors[i] : null,
|
||||
];
|
||||
_seriesNames = parsed.$2.isEmpty
|
||||
? ['Reeks 1']
|
||||
: [for (final s in parsed.$2) s.name];
|
||||
_seriesColors = [
|
||||
for (var i = 0; i < _seriesNames.length; i++)
|
||||
i < _seriesColors.length ? _seriesColors[i] : null,
|
||||
];
|
||||
_values = [
|
||||
for (var r = 0; r < _xLabels.length; r++)
|
||||
[for (final s in parsed.$2) r < s.data.length ? _fmt(s.data[r]) : ''],
|
||||
if (parsed.$2.isEmpty)
|
||||
['']
|
||||
else
|
||||
[
|
||||
for (final s in parsed.$2)
|
||||
r < s.data.length ? _fmt(s.data[r]) : '',
|
||||
],
|
||||
];
|
||||
_rev++;
|
||||
});
|
||||
|
|
@ -216,6 +263,170 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
_emit();
|
||||
}
|
||||
|
||||
void _moveRow(int from, int to) {
|
||||
if (to < 0 || to >= _xLabels.length || from == to) return;
|
||||
setState(() {
|
||||
final label = _xLabels.removeAt(from);
|
||||
final color = _rowColors.removeAt(from);
|
||||
final values = _values.removeAt(from);
|
||||
_xLabels.insert(to, label);
|
||||
_rowColors.insert(to, color);
|
||||
_values.insert(to, values);
|
||||
_rev++;
|
||||
});
|
||||
_emit();
|
||||
}
|
||||
|
||||
void _sortRows({int? column, required bool ascending}) {
|
||||
final indices = List<int>.generate(_xLabels.length, (i) => i);
|
||||
indices.sort((a, b) {
|
||||
int result;
|
||||
if (column == null) {
|
||||
result = _xLabels[a].toLowerCase().compareTo(_xLabels[b].toLowerCase());
|
||||
} else {
|
||||
final av =
|
||||
double.tryParse(_values[a][column].replaceAll(',', '.')) ?? 0;
|
||||
final bv =
|
||||
double.tryParse(_values[b][column].replaceAll(',', '.')) ?? 0;
|
||||
result = av.compareTo(bv);
|
||||
}
|
||||
return ascending ? result : -result;
|
||||
});
|
||||
setState(() {
|
||||
final labels = [for (final i in indices) _xLabels[i]];
|
||||
final colors = [for (final i in indices) _rowColors[i]];
|
||||
final values = [for (final i in indices) _values[i]];
|
||||
_xLabels = labels;
|
||||
_rowColors = colors;
|
||||
_values = values;
|
||||
_rev++;
|
||||
});
|
||||
_emit();
|
||||
}
|
||||
|
||||
Future<void> _pickSeriesColor(int index) async {
|
||||
final selected = await _pickColor(
|
||||
initial:
|
||||
_seriesColors[index] ??
|
||||
chartSeriesColor(ChartSeries(name: '', data: const []), index),
|
||||
title: context.l10n.d('Kleur van reeks'),
|
||||
);
|
||||
if (selected == null || !mounted) return;
|
||||
setState(() => _seriesColors[index] = selected);
|
||||
_emit();
|
||||
}
|
||||
|
||||
Future<void> _pickRowColor(int index) async {
|
||||
final selected = await _pickColor(
|
||||
initial:
|
||||
_rowColors[index] ??
|
||||
chartColorPalette[index % chartColorPalette.length],
|
||||
title: context.l10n.d('Kleur van rij'),
|
||||
);
|
||||
if (selected == null || !mounted) return;
|
||||
setState(() => _rowColors[index] = selected);
|
||||
_emit();
|
||||
}
|
||||
|
||||
Future<String?> _pickColor({
|
||||
required String initial,
|
||||
required String title,
|
||||
}) async {
|
||||
final controller = TextEditingController(text: initial);
|
||||
final selected = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: StatefulBuilder(
|
||||
builder: (context, setDialogState) => SizedBox(
|
||||
width: 320,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
for (final hex in chartColorPalette)
|
||||
_colorChoice(
|
||||
hex,
|
||||
selected: normalizeChartColor(controller.text) == hex,
|
||||
onTap: () {
|
||||
controller.text = hex;
|
||||
setDialogState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.d('Hexkleur'),
|
||||
hintText: '#2563EB',
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[#0-9a-fA-F]')),
|
||||
LengthLimitingTextInputFormatter(7),
|
||||
],
|
||||
onChanged: (_) => setDialogState(() {}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.d('Annuleren')),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: normalizeChartColor(controller.text) == null
|
||||
? null
|
||||
: () => Navigator.pop(
|
||||
context,
|
||||
normalizeChartColor(controller.text),
|
||||
),
|
||||
child: Text(context.l10n.d('Toepassen')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
controller.dispose();
|
||||
return selected;
|
||||
}
|
||||
|
||||
Widget _colorChoice(
|
||||
String hex, {
|
||||
required bool selected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
final color = Color(int.parse(hex.substring(1), radix: 16) | 0xFF000000);
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: selected ? const Color(0xFF0F172A) : Colors.white,
|
||||
width: selected ? 3 : 2,
|
||||
),
|
||||
boxShadow: const [BoxShadow(color: Color(0x330F172A), blurRadius: 3)],
|
||||
),
|
||||
child: selected
|
||||
? const Icon(Icons.check, size: 18, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
|
|
@ -271,6 +482,40 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
),
|
||||
],
|
||||
),
|
||||
if (_type == ChartType.pie)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
l10n.d(
|
||||
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.',
|
||||
),
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)),
|
||||
),
|
||||
),
|
||||
if (_type != ChartType.pie)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _boundField(
|
||||
key: const ValueKey('chart-min-bound'),
|
||||
controller: _minBound,
|
||||
label: l10n.d('Minimumlijn (optioneel)'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _boundField(
|
||||
key: const ValueKey('chart-max-bound'),
|
||||
controller: _maxBound,
|
||||
label: l10n.d('Maximumlijn (optioneel)'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (linked)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
|
|
@ -297,12 +542,20 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final availableWidth = constraints.maxWidth;
|
||||
return SingleChildScrollView(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: _grid(enabled: !linked),
|
||||
child: _grid(
|
||||
enabled: !linked,
|
||||
availableWidth: availableWidth,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (!linked) ...[
|
||||
const SizedBox(height: 8),
|
||||
|
|
@ -327,23 +580,83 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _grid({required bool enabled}) {
|
||||
Widget _grid({required bool enabled, required double availableWidth}) {
|
||||
final cols = _seriesNames.length;
|
||||
return Column(
|
||||
const trailingWidth = 40.0;
|
||||
final labelWidth = math.max(_minLabelW, availableWidth * 0.28);
|
||||
final remaining = availableWidth - labelWidth - trailingWidth;
|
||||
final cellWidth = math.max(_minCellW, remaining / cols);
|
||||
final gridWidth = math.max(
|
||||
availableWidth,
|
||||
labelWidth + cellWidth * cols + trailingWidth,
|
||||
);
|
||||
return SizedBox(
|
||||
key: const ValueKey('chart-grid'),
|
||||
width: gridWidth,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header row: empty label cell + series name fields.
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _labelW,
|
||||
child: _headerHint(context.l10n.d('Label')),
|
||||
),
|
||||
for (var c = 0; c < cols; c++)
|
||||
SizedBox(
|
||||
width: _cellW,
|
||||
width: labelWidth,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _headerHint(context.l10n.d('Label'))),
|
||||
_sortButton(column: null, enabled: enabled),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (var c = 0; c < cols; c++)
|
||||
Container(
|
||||
key: ValueKey('chart-series-column-$c'),
|
||||
width: cellWidth,
|
||||
color: _type == ChartType.pie && c >= 2
|
||||
? const Color(0xFFE2E8F0)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: enabled ? () => _pickSeriesColor(c) : null,
|
||||
tooltip: context.l10n.d('Kleur van reeks'),
|
||||
icon: Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(
|
||||
_type == ChartType.pie && c >= 2
|
||||
? 0xFF94A3B8
|
||||
: int.parse(
|
||||
chartSeriesColor(
|
||||
ChartSeries(
|
||||
name: '',
|
||||
data: const [],
|
||||
color: _seriesColors[c],
|
||||
),
|
||||
c,
|
||||
).substring(1),
|
||||
radix: 16,
|
||||
) |
|
||||
0xFF000000,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 1.5),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x330F172A),
|
||||
blurRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 24,
|
||||
minHeight: 32,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _cell(
|
||||
key: ValueKey('s-$_rev-$c'),
|
||||
|
|
@ -351,8 +664,10 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
enabled: enabled,
|
||||
onChanged: (v) => _seriesNames[c] = v,
|
||||
bold: true,
|
||||
muted: _type == ChartType.pie && c >= 2,
|
||||
),
|
||||
),
|
||||
_sortButton(column: c, enabled: enabled),
|
||||
if (enabled && cols > 1)
|
||||
_iconBtn(Icons.close, () => _removeColumn(c)),
|
||||
],
|
||||
|
|
@ -368,7 +683,25 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _labelW,
|
||||
width: labelWidth,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
key: ValueKey('chart-row-color-$r'),
|
||||
onPressed: enabled ? () => _pickRowColor(r) : null,
|
||||
tooltip: context.l10n.d('Kleur van rij'),
|
||||
icon: _colorDot(
|
||||
_rowColors[r] ??
|
||||
chartColorPalette[r % chartColorPalette.length],
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 26,
|
||||
minHeight: 32,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _cell(
|
||||
key: ValueKey('x-$_rev-$r'),
|
||||
value: _xLabels[r],
|
||||
|
|
@ -376,14 +709,35 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
onChanged: (v) => _xLabels[r] = v,
|
||||
),
|
||||
),
|
||||
if (enabled) ...[
|
||||
_iconBtn(
|
||||
Icons.keyboard_arrow_up,
|
||||
r == 0 ? null : () => _moveRow(r, r - 1),
|
||||
key: ValueKey('chart-row-up-$r'),
|
||||
),
|
||||
_iconBtn(
|
||||
Icons.keyboard_arrow_down,
|
||||
r == _xLabels.length - 1
|
||||
? null
|
||||
: () => _moveRow(r, r + 1),
|
||||
key: ValueKey('chart-row-down-$r'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
for (var c = 0; c < cols; c++)
|
||||
SizedBox(
|
||||
width: _cellW,
|
||||
Container(
|
||||
width: cellWidth,
|
||||
color: _type == ChartType.pie && c >= 2
|
||||
? const Color(0xFFE2E8F0)
|
||||
: null,
|
||||
child: _cell(
|
||||
key: ValueKey('v-$_rev-$r-$c'),
|
||||
value: c < _values[r].length ? _values[r][c] : '',
|
||||
enabled: enabled,
|
||||
number: true,
|
||||
muted: _type == ChartType.pie && c >= 2,
|
||||
onChanged: (v) {
|
||||
while (_values[r].length <= c) {
|
||||
_values[r].add('');
|
||||
|
|
@ -398,9 +752,35 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _boundField({
|
||||
required Key key,
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
}) => TextField(
|
||||
key: key,
|
||||
controller: controller,
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
signed: true,
|
||||
),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,\-]')),
|
||||
],
|
||||
style: const TextStyle(fontSize: 12),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: const TextStyle(fontSize: 12, color: Color(0xFF64748B)),
|
||||
hintText: context.l10n.d('geen'),
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _headerHint(String text) => Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 4),
|
||||
child: Text(
|
||||
|
|
@ -413,6 +793,40 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
),
|
||||
);
|
||||
|
||||
Widget _sortButton({required int? column, required bool enabled}) {
|
||||
return PopupMenuButton<bool>(
|
||||
key: ValueKey('chart-sort-${column ?? 'label'}'),
|
||||
enabled: enabled,
|
||||
tooltip: context.l10n.d('Sorteren'),
|
||||
icon: const Icon(Icons.sort, size: 15, color: Color(0xFF64748B)),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 24, minHeight: 28),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: true,
|
||||
child: Text(context.l10n.d('Oplopend sorteren')),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: false,
|
||||
child: Text(context.l10n.d('Aflopend sorteren')),
|
||||
),
|
||||
],
|
||||
onSelected: (ascending) =>
|
||||
_sortRows(column: column, ascending: ascending),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _colorDot(String hex) => Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(int.parse(hex.substring(1), radix: 16) | 0xFF000000),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 1.5),
|
||||
boxShadow: const [BoxShadow(color: Color(0x330F172A), blurRadius: 2)],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _cell({
|
||||
required Key key,
|
||||
required String value,
|
||||
|
|
@ -420,6 +834,7 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
required ValueChanged<String> onChanged,
|
||||
bool number = false,
|
||||
bool bold = false,
|
||||
bool muted = false,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
|
|
@ -440,17 +855,24 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: bold ? FontWeight.w600 : FontWeight.normal,
|
||||
color: muted ? const Color(0xFF64748B) : null,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
border: OutlineInputBorder(),
|
||||
filled: muted,
|
||||
fillColor: muted ? const Color(0xFFF1F5F9) : null,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 8,
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _iconBtn(IconData icon, VoidCallback onTap) => IconButton(
|
||||
Widget _iconBtn(IconData icon, VoidCallback? onTap, {Key? key}) => IconButton(
|
||||
key: key,
|
||||
onPressed: onTap,
|
||||
icon: Icon(icon, size: 14),
|
||||
color: const Color(0xFF94A3B8),
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
|||
slideNumber: _index + 1,
|
||||
slideCount: _slides.length,
|
||||
tlp: _tlp,
|
||||
presentationMode: true,
|
||||
enableMedia: true,
|
||||
autoplayMedia: true,
|
||||
// Audio finishing on the beamer drives the presenter's
|
||||
|
|
|
|||
|
|
@ -1288,6 +1288,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
slideNumber: _index + 1,
|
||||
slideCount: widget.slides.length,
|
||||
tlp: widget.tlp,
|
||||
presentationMode: true,
|
||||
// Tijdens het presenteren speelt media en starten audio/video
|
||||
// vanzelf; het audio-einde stuurt de auto-advance aan. In dual-
|
||||
// schermmodus speelt de media op het beamervenster, niet hier,
|
||||
|
|
@ -1411,6 +1412,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
slide: nextSlide,
|
||||
projectPath: widget.projectPath,
|
||||
themeProfile: widget.themeProfile,
|
||||
presentationMode: true,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter_highlight/flutter_highlight.dart';
|
||||
|
|
@ -138,6 +140,9 @@ class SlidePreviewWidget extends StatelessWidget {
|
|||
/// staat dit uit (handmatig starten); in de presenter aan.
|
||||
final bool autoplayMedia;
|
||||
|
||||
/// Vergroot grafieklabels voor weergave op afstand in presentatiemodus.
|
||||
final bool presentationMode;
|
||||
|
||||
/// Wordt aangeroepen wanneer de audio van deze slide klaar is (voor de
|
||||
/// automatische modus van de presenter).
|
||||
final VoidCallback? onAudioComplete;
|
||||
|
|
@ -153,6 +158,7 @@ class SlidePreviewWidget extends StatelessWidget {
|
|||
this.tlp = TlpLevel.none,
|
||||
this.enableMedia = false,
|
||||
this.autoplayMedia = false,
|
||||
this.presentationMode = false,
|
||||
this.onAudioComplete,
|
||||
});
|
||||
|
||||
|
|
@ -337,6 +343,7 @@ class SlidePreviewWidget extends StatelessWidget {
|
|||
w: w,
|
||||
font: fontFamily,
|
||||
profile: themeProfile,
|
||||
presentationMode: presentationMode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2163,38 +2170,64 @@ class _CodePreview extends StatelessWidget {
|
|||
}
|
||||
|
||||
/// Renders a chart slide (bar/line/pie) from its ```chart JSON spec.
|
||||
class _ChartPreview extends StatelessWidget {
|
||||
class _ChartPreview extends StatefulWidget {
|
||||
final Slide slide;
|
||||
final double w;
|
||||
final String font;
|
||||
final ThemeProfile profile;
|
||||
final bool presentationMode;
|
||||
|
||||
const _ChartPreview({
|
||||
required this.slide,
|
||||
required this.w,
|
||||
required this.font,
|
||||
required this.profile,
|
||||
required this.presentationMode,
|
||||
});
|
||||
|
||||
static const _palette = <int>[
|
||||
0xFF2563EB,
|
||||
0xFFF59E0B,
|
||||
0xFF10B981,
|
||||
0xFFEF4444,
|
||||
0xFF8B5CF6,
|
||||
0xFF06B6D4,
|
||||
0xFFEC4899,
|
||||
0xFF84CC16,
|
||||
];
|
||||
@override
|
||||
State<_ChartPreview> createState() => _ChartPreviewState();
|
||||
}
|
||||
|
||||
Color _seriesColor(int i) => i == 0
|
||||
? _hexColor(profile.accentColor)
|
||||
: Color(_palette[i % _palette.length]);
|
||||
class _ChartPreviewState extends State<_ChartPreview> {
|
||||
Slide get slide => widget.slide;
|
||||
double get w => widget.w;
|
||||
String get font => widget.font;
|
||||
ThemeProfile get profile => widget.profile;
|
||||
bool get presentationMode => widget.presentationMode;
|
||||
|
||||
/// Legend entry the pointer is over: a series index for bar/line charts, or a
|
||||
/// slice (category) index for pie charts. Null when nothing is hovered.
|
||||
int? _hovered;
|
||||
|
||||
void _setHover(int? index) {
|
||||
if (_hovered != index) setState(() => _hovered = index);
|
||||
}
|
||||
|
||||
/// True when another legend entry is hovered, so [index] should fade back.
|
||||
bool _dimmed(int index) => _hovered != null && _hovered != index;
|
||||
|
||||
/// Series colour with legend-hover feedback: non-hovered series fade out so
|
||||
/// the hovered one stands out in the plot.
|
||||
Color _seriesDisplayColor(ChartSeries series, int i) {
|
||||
final base = _seriesColor(series, i);
|
||||
return _dimmed(i) ? base.withValues(alpha: 0.2) : base;
|
||||
}
|
||||
|
||||
double get _labelScale => presentationMode ? 1.12 : 1;
|
||||
|
||||
Color _seriesColor(ChartSeries series, int i) {
|
||||
if (series.color == null && i == 0) {
|
||||
return _hexColor(profile.accentColor);
|
||||
}
|
||||
return _hexColor(chartSeriesColor(series, i));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final spec = ChartSpec.parse(slide.customMarkdown);
|
||||
final pad = w * 0.06;
|
||||
final horizontalPad = w * 0.05;
|
||||
final verticalPad = w * 0.018;
|
||||
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
|
||||
final textColor = _hexColor(profile.textColor);
|
||||
|
||||
|
|
@ -2202,37 +2235,80 @@ class _ChartPreview extends StatelessWidget {
|
|||
color: _hexColor(profile.slideBackgroundColor),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
pad,
|
||||
pad + safe.top,
|
||||
pad,
|
||||
pad + safe.bottom,
|
||||
horizontalPad,
|
||||
verticalPad + safe.top,
|
||||
horizontalPad,
|
||||
verticalPad + safe.bottom,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (spec.title.isNotEmpty) ...[
|
||||
_md(
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: w * 0.025,
|
||||
vertical: w * 0.01,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _hexColor(profile.titleBackgroundColor),
|
||||
borderRadius: BorderRadius.circular(w * 0.012),
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: _hexColor(profile.accentColor),
|
||||
width: w * 0.006,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: _md(
|
||||
context,
|
||||
spec.title,
|
||||
_applyFont(
|
||||
font,
|
||||
TextStyle(
|
||||
fontSize: w * 0.04,
|
||||
fontSize: w * 0.032,
|
||||
height: 1.1,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textColor,
|
||||
color: _hexColor(profile.titleTextColor),
|
||||
),
|
||||
),
|
||||
linkColor: _hexColor(profile.accentColor),
|
||||
),
|
||||
SizedBox(height: w * 0.02),
|
||||
),
|
||||
SizedBox(height: w * 0.012),
|
||||
],
|
||||
if (spec.series.length > 1 && spec.type != ChartType.pie)
|
||||
_legend(spec, textColor),
|
||||
Expanded(
|
||||
child: Container(
|
||||
key: const ValueKey('chart-surface'),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
w * 0.02,
|
||||
w * 0.01,
|
||||
w * 0.025,
|
||||
w * 0.01,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: textColor.withValues(alpha: 0.035),
|
||||
borderRadius: BorderRadius.circular(w * 0.014),
|
||||
border: Border.all(color: textColor.withValues(alpha: 0.09)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: spec.hasInlineData
|
||||
? _chart(spec, textColor)
|
||||
: _placeholder(context),
|
||||
),
|
||||
if (spec.hasInlineData && spec.series.isNotEmpty) ...[
|
||||
SizedBox(height: w * 0.006),
|
||||
spec.type == ChartType.pie
|
||||
? _pieLegend(spec, textColor)
|
||||
: _legend(spec, textColor),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -2240,36 +2316,154 @@ class _ChartPreview extends StatelessWidget {
|
|||
}
|
||||
|
||||
Widget _legend(ChartSpec spec, Color textColor) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: w * 0.015),
|
||||
child: Wrap(
|
||||
spacing: w * 0.02,
|
||||
runSpacing: w * 0.008,
|
||||
return SizedBox(
|
||||
height: w * 0.03,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < spec.series.length; i++)
|
||||
Row(
|
||||
for (var i = 0; i < spec.series.length; i++) ...[
|
||||
if (i > 0) SizedBox(width: w * 0.01),
|
||||
MouseRegion(
|
||||
onEnter: (_) => _setHover(i),
|
||||
onExit: (_) => _setHover(null),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
opacity: _dimmed(i) ? 0.4 : 1,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: w * 0.01,
|
||||
vertical: w * 0.004,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered == i
|
||||
? _seriesColor(spec.series[i], i).withValues(alpha: 0.18)
|
||||
: textColor.withValues(alpha: 0.045),
|
||||
borderRadius: BorderRadius.circular(w),
|
||||
border: Border.all(
|
||||
color: _hovered == i
|
||||
? _seriesColor(spec.series[i], i)
|
||||
: Colors.transparent,
|
||||
width: w * 0.0015,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: w * 0.018,
|
||||
height: w * 0.018,
|
||||
width: w * 0.012,
|
||||
height: w * 0.012,
|
||||
decoration: BoxDecoration(
|
||||
color: _seriesColor(i),
|
||||
color: _seriesColor(spec.series[i], i),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
SizedBox(width: w * 0.008),
|
||||
Text(
|
||||
spec.series[i].name,
|
||||
SizedBox(width: w * 0.006),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: w * 0.16),
|
||||
child: Text(
|
||||
spec.series[i].name.isEmpty
|
||||
? 'Reeks ${i + 1}'
|
||||
: spec.series[i].name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: _applyFont(
|
||||
font,
|
||||
TextStyle(fontSize: w * 0.02, color: textColor),
|
||||
TextStyle(
|
||||
fontSize: w * 0.013,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textColor.withValues(alpha: 0.82),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pieLegend(ChartSpec spec, Color textColor) {
|
||||
final itemCount = math.min(spec.x.length, 18);
|
||||
final columns = math.min(itemCount, presentationMode ? 4 : 6);
|
||||
final rows = (itemCount / columns).ceil();
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final gap = w * 0.006;
|
||||
final itemWidth =
|
||||
(constraints.maxWidth - gap * (columns - 1)) / columns;
|
||||
return SizedBox(
|
||||
height: rows * w * 0.03 * _labelScale + (rows - 1) * gap,
|
||||
child: Wrap(
|
||||
spacing: gap,
|
||||
runSpacing: gap,
|
||||
children: [
|
||||
for (var i = 0; i < itemCount; i++)
|
||||
MouseRegion(
|
||||
onEnter: (_) => _setHover(i),
|
||||
onExit: (_) => _setHover(null),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
opacity: _dimmed(i) ? 0.4 : 1,
|
||||
child: Container(
|
||||
width: itemWidth,
|
||||
height: w * 0.03 * _labelScale,
|
||||
padding: EdgeInsets.symmetric(horizontal: w * 0.008),
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered == i
|
||||
? _hexColor(
|
||||
chartRowColor(spec, i),
|
||||
).withValues(alpha: 0.18)
|
||||
: textColor.withValues(alpha: 0.045),
|
||||
borderRadius: BorderRadius.circular(w),
|
||||
border: Border.all(
|
||||
color: _hovered == i
|
||||
? _hexColor(chartRowColor(spec, i))
|
||||
: Colors.transparent,
|
||||
width: w * 0.0015,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: w * 0.012,
|
||||
height: w * 0.012,
|
||||
decoration: BoxDecoration(
|
||||
color: _hexColor(chartRowColor(spec, i)),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
SizedBox(width: w * 0.006),
|
||||
Expanded(
|
||||
child: Text(
|
||||
spec.x[i],
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: _applyFont(
|
||||
font,
|
||||
TextStyle(
|
||||
fontSize: w * 0.013 * _labelScale,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textColor.withValues(alpha: 0.82),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2291,13 +2485,76 @@ class _ChartPreview extends StatelessWidget {
|
|||
if (v > m) m = v;
|
||||
}
|
||||
}
|
||||
// Keep any bound line comfortably inside the plot so its label is visible.
|
||||
if (spec.supportsBounds) {
|
||||
for (final b in [spec.minBound, spec.maxBound]) {
|
||||
if (b != null && b > m) m = b;
|
||||
}
|
||||
}
|
||||
return m <= 0 ? 1 : m * 1.15;
|
||||
}
|
||||
|
||||
double _minY(ChartSpec spec) {
|
||||
var m = 0.0;
|
||||
for (final s in spec.series) {
|
||||
for (final v in s.data) {
|
||||
if (v < m) m = v;
|
||||
}
|
||||
}
|
||||
if (spec.supportsBounds) {
|
||||
for (final b in [spec.minBound, spec.maxBound]) {
|
||||
if (b != null && b < m) m = b;
|
||||
}
|
||||
}
|
||||
return m >= 0 ? 0 : m * 1.15;
|
||||
}
|
||||
|
||||
/// Optional min/max threshold lines drawn across the plot (bar/line only).
|
||||
ExtraLinesData _boundLines(ChartSpec spec) {
|
||||
if (!spec.supportsBounds) return const ExtraLinesData();
|
||||
final dash = [
|
||||
(w * 0.018).round().clamp(4, 14),
|
||||
(w * 0.01).round().clamp(3, 9),
|
||||
];
|
||||
HorizontalLine line(double value, Color color, String prefix) =>
|
||||
HorizontalLine(
|
||||
y: value,
|
||||
color: color,
|
||||
strokeWidth: w * 0.0035,
|
||||
dashArray: dash,
|
||||
label: HorizontalLineLabel(
|
||||
show: true,
|
||||
alignment: Alignment.topRight,
|
||||
padding: EdgeInsets.only(right: w * 0.006, bottom: w * 0.002),
|
||||
style: _applyFont(
|
||||
font,
|
||||
TextStyle(
|
||||
fontSize: w * 0.0115 * _labelScale,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
labelResolver: (_) => '$prefix ${_fmtNum(value)}',
|
||||
),
|
||||
);
|
||||
return ExtraLinesData(
|
||||
horizontalLines: [
|
||||
if (spec.minBound != null)
|
||||
line(spec.minBound!, const Color(0xFFF59E0B), 'min'),
|
||||
if (spec.maxBound != null)
|
||||
line(spec.maxBound!, const Color(0xFFEF4444), 'max'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
FlTitlesData _titles(ChartSpec spec, Color textColor) {
|
||||
final style = _applyFont(
|
||||
font,
|
||||
TextStyle(fontSize: w * 0.018, color: textColor.withValues(alpha: 0.8)),
|
||||
TextStyle(
|
||||
fontSize: w * 0.0115 * _labelScale,
|
||||
color: textColor.withValues(alpha: 0.88),
|
||||
fontWeight: presentationMode ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
);
|
||||
return FlTitlesData(
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
|
|
@ -2305,21 +2562,46 @@ class _ChartPreview extends StatelessWidget {
|
|||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: w * 0.06,
|
||||
getTitlesWidget: (value, meta) =>
|
||||
Text(_fmtNum(value), style: style.copyWith(fontSize: w * 0.016)),
|
||||
reservedSize: w * 0.05 * _labelScale,
|
||||
getTitlesWidget: (value, meta) => Text(
|
||||
_fmtNum(value),
|
||||
style: style.copyWith(fontSize: w * 0.0105 * _labelScale),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: w * 0.05,
|
||||
interval: 1,
|
||||
reservedSize: w * 0.044 * _labelScale,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final i = value.round();
|
||||
if (i < 0 || i >= spec.x.length) return const SizedBox.shrink();
|
||||
final n = spec.x.length;
|
||||
if (i < 0 || i >= n) return const SizedBox.shrink();
|
||||
// Show as many labels as fit without colliding: keep at least
|
||||
// [minSlot] of horizontal room per label, then thin them out
|
||||
// evenly based on the actual pixel spacing between points.
|
||||
final spacing = n > 1
|
||||
? meta.parentAxisSize / (n - 1)
|
||||
: meta.parentAxisSize;
|
||||
final minSlot = w * 0.085 * _labelScale;
|
||||
final step = math.max(1, (minSlot / spacing).ceil());
|
||||
final lastMultiple = ((n - 1) ~/ step) * step;
|
||||
final showLast = i == n - 1 && (n - 1 - lastMultiple) > step / 2;
|
||||
if (i % step != 0 && !showLast) return const SizedBox.shrink();
|
||||
final slot = (step * spacing - w * 0.012).clamp(w * 0.04, w * 0.16);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: w * 0.008),
|
||||
child: Text(spec.x[i], style: style),
|
||||
child: SizedBox(
|
||||
width: slot,
|
||||
child: Text(
|
||||
spec.x[i],
|
||||
style: style,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -2350,9 +2632,19 @@ class _ChartPreview extends StatelessWidget {
|
|||
if (xi < spec.series[si].data.length)
|
||||
BarChartRodData(
|
||||
toY: spec.series[si].data[xi],
|
||||
color: _seriesColor(si),
|
||||
width: w * 0.012,
|
||||
borderRadius: BorderRadius.circular(w * 0.003),
|
||||
color: _seriesDisplayColor(spec.series[si], si),
|
||||
width: (w * 0.032 / spec.series.length).clamp(
|
||||
w * 0.008,
|
||||
w * 0.022,
|
||||
),
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(w * 0.006),
|
||||
),
|
||||
backDrawRodData: BackgroundBarChartRodData(
|
||||
show: true,
|
||||
toY: _maxY(spec),
|
||||
color: textColor.withValues(alpha: 0.025),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -2360,12 +2652,36 @@ class _ChartPreview extends StatelessWidget {
|
|||
}
|
||||
return BarChart(
|
||||
BarChartData(
|
||||
minY: _minY(spec),
|
||||
maxY: _maxY(spec),
|
||||
barGroups: groups,
|
||||
titlesData: _titles(spec, textColor),
|
||||
gridData: _grid(textColor),
|
||||
borderData: FlBorderData(show: false),
|
||||
barTouchData: BarTouchData(enabled: false),
|
||||
extraLinesData: _boundLines(spec),
|
||||
barTouchData: BarTouchData(
|
||||
enabled: true,
|
||||
mouseCursorResolver: (event, response) => response?.spot == null
|
||||
? SystemMouseCursors.basic
|
||||
: SystemMouseCursors.click,
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
fitInsideHorizontally: true,
|
||||
fitInsideVertically: true,
|
||||
getTooltipColor: (_) => const Color(0xFF0F172A),
|
||||
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
||||
final label = group.x >= 0 && group.x < spec.x.length
|
||||
? spec.x[group.x]
|
||||
: '';
|
||||
final series = rodIndex < spec.series.length
|
||||
? spec.series[rodIndex].name
|
||||
: '';
|
||||
return BarTooltipItem(
|
||||
'$label\n${series.isEmpty ? 'Reeks ${rodIndex + 1}' : series}: ${_fmtNum(rod.toY)}',
|
||||
_tooltipStyle(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
duration: Duration.zero,
|
||||
);
|
||||
|
|
@ -2380,108 +2696,200 @@ class _ChartPreview extends StatelessWidget {
|
|||
for (var xi = 0; xi < spec.series[si].data.length; xi++)
|
||||
FlSpot(xi.toDouble(), spec.series[si].data[xi]),
|
||||
],
|
||||
color: _seriesColor(si),
|
||||
barWidth: w * 0.004,
|
||||
isCurved: false,
|
||||
dotData: const FlDotData(show: true),
|
||||
color: _seriesDisplayColor(spec.series[si], si),
|
||||
barWidth: w * (_hovered == si ? 0.0065 : 0.0045),
|
||||
isCurved: true,
|
||||
curveSmoothness: 0.22,
|
||||
dotData: FlDotData(
|
||||
show: true,
|
||||
getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter(
|
||||
radius: w * 0.005,
|
||||
color: _seriesDisplayColor(spec.series[si], si),
|
||||
strokeWidth: w * 0.0025,
|
||||
strokeColor: _hexColor(profile.slideBackgroundColor),
|
||||
),
|
||||
),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: _seriesDisplayColor(spec.series[si], si).withValues(
|
||||
alpha: spec.series.length == 1 ? 0.14 : 0.05,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return LineChart(
|
||||
LineChartData(
|
||||
minY: 0,
|
||||
minY: _minY(spec),
|
||||
maxY: _maxY(spec),
|
||||
lineBarsData: bars,
|
||||
titlesData: _titles(spec, textColor),
|
||||
gridData: _grid(textColor),
|
||||
borderData: FlBorderData(show: false),
|
||||
lineTouchData: const LineTouchData(enabled: false),
|
||||
extraLinesData: _boundLines(spec),
|
||||
lineTouchData: LineTouchData(
|
||||
enabled: true,
|
||||
mouseCursorResolver: (event, response) =>
|
||||
response?.lineBarSpots?.isEmpty ?? true
|
||||
? SystemMouseCursors.basic
|
||||
: SystemMouseCursors.click,
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
fitInsideHorizontally: true,
|
||||
fitInsideVertically: true,
|
||||
getTooltipColor: (_) => const Color(0xFF0F172A),
|
||||
getTooltipItems: (spots) {
|
||||
// When several series cross the same x, fl_chart hands us one
|
||||
// spot per series. Only show the value of the point closest to
|
||||
// the cursor instead of stacking every series vertically.
|
||||
var nearest = 0;
|
||||
var best = double.infinity;
|
||||
for (var k = 0; k < spots.length; k++) {
|
||||
final s = spots[k];
|
||||
final d = s is TouchLineBarSpot ? s.distance : 0.0;
|
||||
if (d < best) {
|
||||
best = d;
|
||||
nearest = k;
|
||||
}
|
||||
}
|
||||
return [
|
||||
for (var k = 0; k < spots.length; k++)
|
||||
if (k != nearest)
|
||||
null
|
||||
else
|
||||
LineTooltipItem(
|
||||
'${spots[k].spotIndex < spec.x.length ? spec.x[spots[k].spotIndex] : ''}\n'
|
||||
'${spots[k].barIndex < spec.series.length && spec.series[spots[k].barIndex].name.isNotEmpty ? spec.series[spots[k].barIndex].name : 'Reeks ${spots[k].barIndex + 1}'}: ${_fmtNum(spots[k].y)}',
|
||||
_tooltipStyle(),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
duration: Duration.zero,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pieChart(ChartSpec spec, Color textColor) {
|
||||
// A pie uses the first series; each slice is an x label.
|
||||
final series = spec.series.isNotEmpty ? spec.series.first : null;
|
||||
if (series == null) return _placeholderText('—');
|
||||
final total = series.data.fold<double>(0, (a, b) => a + b);
|
||||
final sections = <PieChartSectionData>[];
|
||||
for (var i = 0; i < series.data.length; i++) {
|
||||
final v = series.data[i];
|
||||
final pct = total > 0 ? (v / total * 100) : 0;
|
||||
sections.add(
|
||||
PieChartSectionData(
|
||||
value: v,
|
||||
color: _seriesColor(i),
|
||||
title: '${pct.toStringAsFixed(0)}%',
|
||||
radius: w * 0.16,
|
||||
if (spec.series.isEmpty || spec.x.isEmpty) {
|
||||
return _placeholderText('—');
|
||||
}
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final visibleSeries = math.min(spec.series.length, 2);
|
||||
final columns = visibleSeries;
|
||||
const rows = 1;
|
||||
final tileHeight = constraints.maxHeight / rows;
|
||||
final tileWidth = constraints.maxWidth / columns;
|
||||
return GridView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columns,
|
||||
childAspectRatio: tileWidth / tileHeight,
|
||||
crossAxisSpacing: w * 0.012,
|
||||
mainAxisSpacing: w * 0.008,
|
||||
),
|
||||
itemCount: visibleSeries,
|
||||
itemBuilder: (context, si) {
|
||||
final series = spec.series[si];
|
||||
final values = [
|
||||
for (var xi = 0; xi < spec.x.length; xi++)
|
||||
xi < series.data.length && series.data[xi] > 0
|
||||
? series.data[xi]
|
||||
: 0.0,
|
||||
];
|
||||
final total = values.fold<double>(0, (a, b) => a + b);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: total <= 0
|
||||
? Center(
|
||||
child: Text(
|
||||
'0',
|
||||
style: _applyFont(
|
||||
font,
|
||||
TextStyle(
|
||||
fontSize: w * 0.025,
|
||||
color: textColor.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: LayoutBuilder(
|
||||
builder: (context, pieConstraints) {
|
||||
final available =
|
||||
pieConstraints.biggest.shortestSide;
|
||||
final radius = (available * 0.42).clamp(
|
||||
w * 0.018,
|
||||
w * 0.075,
|
||||
);
|
||||
return ClipRect(
|
||||
child: _HoverPieChart(
|
||||
externalHover: _hovered,
|
||||
values: values,
|
||||
labels: spec.x,
|
||||
colors: [
|
||||
for (var xi = 0; xi < values.length; xi++)
|
||||
_hexColor(chartRowColor(spec, xi)),
|
||||
],
|
||||
radius: radius,
|
||||
centerSpaceRadius: radius * 0.42,
|
||||
sectionSpace: w * 0.002,
|
||||
titleStyle: _applyFont(
|
||||
font,
|
||||
TextStyle(
|
||||
fontSize: w * 0.02,
|
||||
fontSize: (radius * 0.18).clamp(
|
||||
w * 0.009,
|
||||
w * 0.013,
|
||||
),
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
tooltipStyle: _tooltipStyle(),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
sections: sections,
|
||||
sectionsSpace: 1,
|
||||
centerSpaceRadius: w * 0.05,
|
||||
pieTouchData: PieTouchData(enabled: false),
|
||||
),
|
||||
duration: Duration.zero,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (var i = 0; i < spec.x.length && i < series.data.length; i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: w * 0.004),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: w * 0.018,
|
||||
height: w * 0.018,
|
||||
decoration: BoxDecoration(
|
||||
color: _seriesColor(i),
|
||||
shape: BoxShape.circle,
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(width: w * 0.008),
|
||||
Flexible(
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
spec.x[i],
|
||||
series.name.isEmpty ? 'Reeks ${si + 1}' : series.name,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: _applyFont(
|
||||
font,
|
||||
TextStyle(fontSize: w * 0.02, color: textColor),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
TextStyle(
|
||||
fontSize: w * 0.015,
|
||||
height: 1.1,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
TextStyle _tooltipStyle() => _applyFont(
|
||||
font,
|
||||
TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: (w * 0.013 * _labelScale).clamp(11, 18),
|
||||
height: 1.25,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _placeholder(BuildContext context) =>
|
||||
_placeholderText(context.l10n.d('Geen grafiekgegevens'));
|
||||
|
||||
|
|
@ -2504,6 +2912,121 @@ class _ChartPreview extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
class _HoverPieChart extends StatefulWidget {
|
||||
final List<double> values;
|
||||
final List<String> labels;
|
||||
final List<Color> colors;
|
||||
final double radius;
|
||||
final double centerSpaceRadius;
|
||||
final double sectionSpace;
|
||||
final TextStyle titleStyle;
|
||||
final TextStyle tooltipStyle;
|
||||
|
||||
/// Slice index highlighted from outside (e.g. hovering the legend), combined
|
||||
/// with this chart's own touch hover.
|
||||
final int? externalHover;
|
||||
|
||||
const _HoverPieChart({
|
||||
required this.values,
|
||||
required this.labels,
|
||||
required this.colors,
|
||||
required this.radius,
|
||||
required this.centerSpaceRadius,
|
||||
required this.sectionSpace,
|
||||
required this.titleStyle,
|
||||
required this.tooltipStyle,
|
||||
this.externalHover,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_HoverPieChart> createState() => _HoverPieChartState();
|
||||
}
|
||||
|
||||
class _HoverPieChartState extends State<_HoverPieChart> {
|
||||
int? _hovered;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final total = widget.values.fold<double>(0, (a, b) => a + b);
|
||||
final external = widget.externalHover;
|
||||
final hovered = _hovered ?? (external != null && external >= 0 && external < widget.values.length ? external : null);
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
sections: [
|
||||
for (var i = 0; i < widget.values.length; i++)
|
||||
PieChartSectionData(
|
||||
value: widget.values[i],
|
||||
color: widget.colors[i],
|
||||
title: widget.values[i] / total >= 0.08
|
||||
? '${(widget.values[i] / total * 100).round()}%'
|
||||
: '',
|
||||
radius: widget.radius * (hovered == i ? 1.08 : 1),
|
||||
titleStyle: widget.titleStyle,
|
||||
),
|
||||
],
|
||||
sectionsSpace: widget.sectionSpace,
|
||||
centerSpaceRadius: widget.centerSpaceRadius,
|
||||
pieTouchData: PieTouchData(
|
||||
enabled: true,
|
||||
mouseCursorResolver: (event, response) =>
|
||||
response?.touchedSection == null
|
||||
? SystemMouseCursors.basic
|
||||
: SystemMouseCursors.click,
|
||||
touchCallback: (event, response) {
|
||||
final next = event.isInterestedForInteractions
|
||||
? response?.touchedSection?.touchedSectionIndex
|
||||
: null;
|
||||
if (next != _hovered) setState(() => _hovered = next);
|
||||
},
|
||||
),
|
||||
),
|
||||
duration: Duration.zero,
|
||||
),
|
||||
),
|
||||
if (hovered != null && hovered >= 0 && hovered < widget.values.length)
|
||||
Positioned(
|
||||
top: 4,
|
||||
left: 4,
|
||||
right: 4,
|
||||
child: IgnorePointer(
|
||||
child: Center(
|
||||
child: Container(
|
||||
key: const ValueKey('pie-hover-tooltip'),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0F172A),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Color(0x33000000), blurRadius: 6),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
'${widget.labels[hovered]}: ${_formatChartValue(widget.values[hovered])}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: widget.tooltipStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatChartValue(double value) => value == value.roundToDouble()
|
||||
? value.toInt().toString()
|
||||
: value.toStringAsFixed(1);
|
||||
|
||||
/// Register highlight.js language definitions once, so [HighlightView] can
|
||||
/// colour any common language without throwing.
|
||||
bool _highlightReady = false;
|
||||
|
|
@ -2903,13 +3426,30 @@ Widget _mediaPlaceholder(IconData icon, String label) {
|
|||
}
|
||||
|
||||
Widget _imagePlaceholder(BuildContext context) {
|
||||
return Container(
|
||||
return ColoredBox(
|
||||
color: const Color(0xFFE2E8F0),
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final shortestSide = constraints.biggest.shortestSide;
|
||||
if (shortestSide < 48) {
|
||||
return Center(
|
||||
child: Icon(
|
||||
Icons.image_outlined,
|
||||
color: const Color(0xFF94A3B8),
|
||||
size: shortestSide * 0.65,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 24),
|
||||
const Icon(
|
||||
Icons.image_outlined,
|
||||
color: Color(0xFF94A3B8),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.d('Afbeelding'),
|
||||
|
|
@ -2917,6 +3457,8 @@ Widget _imagePlaceholder(BuildContext context) {
|
|||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
166
test/chart_editor_test.dart
Normal file
166
test/chart_editor_test.dart
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/models/chart.dart';
|
||||
import 'package:ocideck/models/slide.dart';
|
||||
import 'package:ocideck/widgets/editors/chart_editor.dart';
|
||||
|
||||
Widget _host(Slide slide, ValueChanged<Slide> onUpdate) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 900,
|
||||
height: 650,
|
||||
child: ChartEditor(slide: slide, onUpdate: onUpdate),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('chart grid fills the available editor width', (tester) async {
|
||||
const spec = ChartSpec(
|
||||
x: ['A', 'B'],
|
||||
series: [
|
||||
ChartSeries(name: 'Waarde', data: [10, 20]),
|
||||
],
|
||||
);
|
||||
final slide = Slide.create(
|
||||
SlideType.chart,
|
||||
).copyWith(customMarkdown: spec.toBlock());
|
||||
|
||||
await tester.pumpWidget(_host(slide, (_) {}));
|
||||
await tester.pump();
|
||||
|
||||
final gridWidth = tester
|
||||
.getSize(find.byKey(const ValueKey('chart-grid')))
|
||||
.width;
|
||||
expect(gridWidth, greaterThanOrEqualTo(760));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('moving a row keeps its values and color together', (
|
||||
tester,
|
||||
) async {
|
||||
const spec = ChartSpec(
|
||||
x: ['B', 'A'],
|
||||
rowColors: ['#EF4444', '#10B981'],
|
||||
series: [
|
||||
ChartSeries(name: 'Waarde', data: [20, 10]),
|
||||
],
|
||||
);
|
||||
var updated = Slide.create(
|
||||
SlideType.chart,
|
||||
).copyWith(customMarkdown: spec.toBlock());
|
||||
|
||||
await tester.pumpWidget(_host(updated, (slide) => updated = slide));
|
||||
await tester.tap(find.byKey(const ValueKey('chart-row-up-1')));
|
||||
await tester.pump();
|
||||
|
||||
final result = ChartSpec.parse(updated.customMarkdown);
|
||||
expect(result.x, ['A', 'B']);
|
||||
expect(result.rowColors, ['#10B981', '#EF4444']);
|
||||
expect(result.series.single.data, [10, 20]);
|
||||
});
|
||||
|
||||
testWidgets('sorting a value column moves complete rows', (tester) async {
|
||||
const spec = ChartSpec(
|
||||
x: ['A', 'B', 'C'],
|
||||
rowColors: ['#003399', '#FFCC00', '#EF4444'],
|
||||
series: [
|
||||
ChartSeries(name: 'Waarde', data: [30, 10, 20]),
|
||||
],
|
||||
);
|
||||
var updated = Slide.create(
|
||||
SlideType.chart,
|
||||
).copyWith(customMarkdown: spec.toBlock());
|
||||
|
||||
await tester.pumpWidget(_host(updated, (slide) => updated = slide));
|
||||
await tester.tap(find.byKey(const ValueKey('chart-sort-0')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Oplopend sorteren'));
|
||||
await tester.pump();
|
||||
|
||||
final result = ChartSpec.parse(updated.customMarkdown);
|
||||
expect(result.x, ['B', 'C', 'A']);
|
||||
expect(result.rowColors, ['#FFCC00', '#EF4444', '#003399']);
|
||||
expect(result.series.single.data, [10, 20, 30]);
|
||||
});
|
||||
|
||||
testWidgets('pie dims the third series without disabling its input', (
|
||||
tester,
|
||||
) async {
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.pie,
|
||||
x: ['A'],
|
||||
series: [
|
||||
ChartSeries(name: 'Een', data: [1]),
|
||||
ChartSeries(name: 'Twee', data: [2]),
|
||||
ChartSeries(name: 'Drie', data: [3]),
|
||||
],
|
||||
);
|
||||
final slide = Slide.create(
|
||||
SlideType.chart,
|
||||
).copyWith(customMarkdown: spec.toBlock());
|
||||
|
||||
await tester.pumpWidget(_host(slide, (_) {}));
|
||||
await tester.pump();
|
||||
|
||||
final column = tester.widget<Container>(
|
||||
find.byKey(const ValueKey('chart-series-column-2')),
|
||||
);
|
||||
expect(column.color, const Color(0xFFE2E8F0));
|
||||
final input = tester.widget<TextFormField>(
|
||||
find.byKey(const ValueKey('v-0-0-2')),
|
||||
);
|
||||
expect(input.enabled, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('bound fields are offered for bar/line and emit min/max', (
|
||||
tester,
|
||||
) async {
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.bar,
|
||||
x: ['A'],
|
||||
series: [
|
||||
ChartSeries(name: 'Waarde', data: [10]),
|
||||
],
|
||||
);
|
||||
var updated = Slide.create(
|
||||
SlideType.chart,
|
||||
).copyWith(customMarkdown: spec.toBlock());
|
||||
|
||||
await tester.pumpWidget(_host(updated, (slide) => updated = slide));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(const ValueKey('chart-min-bound')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('chart-max-bound')), findsOneWidget);
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('chart-max-bound')),
|
||||
'20',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(ChartSpec.parse(updated.customMarkdown).maxBound, 20);
|
||||
});
|
||||
|
||||
testWidgets('bound fields are hidden for a pie chart', (tester) async {
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.pie,
|
||||
x: ['A'],
|
||||
series: [
|
||||
ChartSeries(name: 'Een', data: [1]),
|
||||
],
|
||||
);
|
||||
final slide = Slide.create(
|
||||
SlideType.chart,
|
||||
).copyWith(customMarkdown: spec.toBlock());
|
||||
|
||||
await tester.pumpWidget(_host(slide, (_) {}));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(const ValueKey('chart-min-bound')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('chart-max-bound')), findsNothing);
|
||||
});
|
||||
}
|
||||
340
test/chart_preview_test.dart
Normal file
340
test/chart_preview_test.dart
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/models/chart.dart';
|
||||
import 'package:ocideck/models/slide.dart';
|
||||
import 'package:ocideck/widgets/slides/slide_preview.dart';
|
||||
|
||||
Widget _host(ChartSpec spec, {bool presentationMode = false}) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 800,
|
||||
height: 450,
|
||||
child: SlidePreviewWidget(
|
||||
slide: Slide.create(
|
||||
SlideType.chart,
|
||||
).copyWith(customMarkdown: spec.toBlock()),
|
||||
presentationMode: presentationMode,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('chart title stays above the plot area', (tester) async {
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.bar,
|
||||
title: 'Omzet per kwartaal',
|
||||
x: ['Q1', 'Q2'],
|
||||
series: [
|
||||
ChartSeries(name: '2026', data: [10, 14]),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_host(spec));
|
||||
await tester.pump();
|
||||
|
||||
final titleBottom = tester.getBottomLeft(find.text(spec.title)).dy;
|
||||
final plotTop = tester.getTopLeft(find.byType(BarChart)).dy;
|
||||
expect(titleBottom, lessThan(plotTop));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('pie renders one chart per series with labels as slices', (
|
||||
tester,
|
||||
) async {
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.pie,
|
||||
title: 'Verdeling',
|
||||
x: ['Team A', 'Team B'],
|
||||
series: [
|
||||
ChartSeries(name: 'Gereed', data: [70, 40], color: '#10B981'),
|
||||
ChartSeries(name: 'Open', data: [30, 60], color: '#EF4444'),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_host(spec));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(PieChart), findsNWidgets(2));
|
||||
expect(find.text('Team A'), findsOneWidget);
|
||||
expect(find.text('Team B'), findsOneWidget);
|
||||
expect(find.text('Gereed'), findsOneWidget);
|
||||
expect(find.text('Open'), findsOneWidget);
|
||||
final pieRect = tester.getRect(find.byType(PieChart).first);
|
||||
final titleRect = tester.getRect(find.text('Gereed'));
|
||||
expect(titleRect.left, greaterThan(pieRect.center.dx));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('bar chart uses most of the available vertical plot area', (
|
||||
tester,
|
||||
) async {
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.bar,
|
||||
title: 'Compacte titel',
|
||||
x: ['A', 'B', 'C'],
|
||||
series: [
|
||||
ChartSeries(name: 'Waarde', data: [10, 20, 15]),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_host(spec));
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.getSize(find.byType(BarChart)).height, greaterThan(260));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('chart surface fills the remaining slide height', (tester) async {
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.bar,
|
||||
title: 'Titel',
|
||||
x: ['A', 'B'],
|
||||
series: [
|
||||
ChartSeries(name: 'Waarde', data: [10, 20]),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_host(spec));
|
||||
await tester.pump();
|
||||
|
||||
final slide = tester.getRect(find.byType(SlidePreviewWidget));
|
||||
final surface = tester.getRect(find.byKey(const ValueKey('chart-surface')));
|
||||
expect(surface.height, greaterThan(slide.height * 0.72));
|
||||
expect(slide.bottom - surface.bottom, lessThan(slide.height * 0.04));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('bar and line hover tooltips show labels and values', (
|
||||
tester,
|
||||
) async {
|
||||
const barSpec = ChartSpec(
|
||||
type: ChartType.bar,
|
||||
x: ['Januari'],
|
||||
series: [
|
||||
ChartSeries(name: 'Omzet', data: [42]),
|
||||
],
|
||||
);
|
||||
await tester.pumpWidget(_host(barSpec));
|
||||
final bar = tester.widget<BarChart>(find.byType(BarChart));
|
||||
final barItem = bar.data.barTouchData.touchTooltipData.getTooltipItem(
|
||||
bar.data.barGroups.single,
|
||||
0,
|
||||
bar.data.barGroups.single.barRods.single,
|
||||
0,
|
||||
);
|
||||
expect(barItem?.text, 'Januari\nOmzet: 42');
|
||||
|
||||
const lineSpec = ChartSpec(
|
||||
type: ChartType.line,
|
||||
x: ['Februari'],
|
||||
series: [
|
||||
ChartSeries(name: 'Bezoekers', data: [17.5]),
|
||||
],
|
||||
);
|
||||
await tester.pumpWidget(_host(lineSpec));
|
||||
final line = tester.widget<LineChart>(find.byType(LineChart));
|
||||
final spot = LineBarSpot(
|
||||
line.data.lineBarsData.single,
|
||||
0,
|
||||
line.data.lineBarsData.single.spots.single,
|
||||
);
|
||||
final lineItems = line.data.lineTouchData.touchTooltipData.getTooltipItems([
|
||||
spot,
|
||||
]);
|
||||
expect(lineItems.single?.text, 'Februari\nBezoekers: 17.5');
|
||||
});
|
||||
|
||||
testWidgets('pie hover shows the underlying category value', (tester) async {
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.pie,
|
||||
x: ['Gereed', 'Open'],
|
||||
series: [
|
||||
ChartSeries(name: 'Status', data: [70, 30]),
|
||||
],
|
||||
);
|
||||
await tester.pumpWidget(_host(spec));
|
||||
await tester.pump();
|
||||
|
||||
final pie = tester.widget<PieChart>(find.byType(PieChart));
|
||||
final section = pie.data.sections.first;
|
||||
pie.data.pieTouchData.touchCallback!(
|
||||
const FlPointerHoverEvent(PointerHoverEvent()),
|
||||
PieTouchResponse(
|
||||
touchLocation: Offset.zero,
|
||||
touchedSection: PieTouchedSection(section, 0, 0, section.radius),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(const ValueKey('pie-hover-tooltip')), findsOneWidget);
|
||||
expect(find.text('Gereed: 70'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('bar chart draws the configured min/max bound lines', (
|
||||
tester,
|
||||
) async {
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.bar,
|
||||
x: ['Q1'],
|
||||
series: [
|
||||
ChartSeries(name: 'Omzet', data: [10]),
|
||||
],
|
||||
minBound: 5,
|
||||
maxBound: 20,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_host(spec));
|
||||
await tester.pump();
|
||||
|
||||
final bar = tester.widget<BarChart>(find.byType(BarChart));
|
||||
final ys = bar.data.extraLinesData.horizontalLines.map((l) => l.y).toList();
|
||||
expect(ys, containsAll(<double>[5, 20]));
|
||||
// The max bound widens the axis so the line stays inside the plot.
|
||||
expect(bar.data.maxY, greaterThanOrEqualTo(20));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('hovering a legend entry fades the other series', (tester) async {
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.line,
|
||||
x: ['Q1', 'Q2'],
|
||||
series: [
|
||||
ChartSeries(name: 'Alpha', data: [10, 12], color: '#2563EB'),
|
||||
ChartSeries(name: 'Beta', data: [8, 9], color: '#EF4444'),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_host(spec));
|
||||
await tester.pump();
|
||||
|
||||
var line = tester.widget<LineChart>(find.byType(LineChart));
|
||||
expect(line.data.lineBarsData[0].color!.a, 1.0);
|
||||
expect(line.data.lineBarsData[1].color!.a, 1.0);
|
||||
|
||||
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
await gesture.addPointer(location: Offset.zero);
|
||||
addTearDown(gesture.removePointer);
|
||||
await gesture.moveTo(tester.getCenter(find.text('Alpha')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
line = tester.widget<LineChart>(find.byType(LineChart));
|
||||
expect(line.data.lineBarsData[0].color!.a, 1.0); // hovered stays solid
|
||||
expect(line.data.lineBarsData[1].color!.a, lessThan(1.0)); // other fades
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('presentation mode enlarges chart labels', (tester) async {
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.bar,
|
||||
x: ['Categorie'],
|
||||
series: [
|
||||
ChartSeries(name: 'Waarde', data: [10]),
|
||||
],
|
||||
);
|
||||
await tester.pumpWidget(_host(spec));
|
||||
final normal = tester.widget<Text>(find.text('Categorie').first);
|
||||
final normalSize = normal.style!.fontSize!;
|
||||
expect(normalSize, lessThanOrEqualTo(12));
|
||||
|
||||
await tester.pumpWidget(_host(spec, presentationMode: true));
|
||||
final presented = tester.widget<Text>(find.text('Categorie').first);
|
||||
expect(presented.style!.fontSize!, greaterThan(normalSize));
|
||||
});
|
||||
|
||||
testWidgets('dense axis labels are thinned and stay inside the slide', (
|
||||
tester,
|
||||
) async {
|
||||
const labels = [
|
||||
'Januari bijzonder lang',
|
||||
'Februari bijzonder lang',
|
||||
'Maart bijzonder lang',
|
||||
'April bijzonder lang',
|
||||
'Mei bijzonder lang',
|
||||
'Juni bijzonder lang',
|
||||
'Juli bijzonder lang',
|
||||
'Augustus bijzonder lang',
|
||||
'September bijzonder lang',
|
||||
'Oktober bijzonder lang',
|
||||
'November bijzonder lang',
|
||||
'December bijzonder lang',
|
||||
];
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.line,
|
||||
x: labels,
|
||||
series: [
|
||||
ChartSeries(
|
||||
name: 'Waarde',
|
||||
data: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_host(spec));
|
||||
await tester.pump();
|
||||
|
||||
final visibleLabels = [
|
||||
for (final label in labels)
|
||||
if (find.text(label).evaluate().isNotEmpty) label,
|
||||
];
|
||||
expect(visibleLabels.length, lessThanOrEqualTo(8));
|
||||
final slideRect = tester.getRect(find.byType(SlidePreviewWidget));
|
||||
for (final label in visibleLabels) {
|
||||
final rect = tester.getRect(find.text(label).first);
|
||||
expect(slideRect.contains(rect.topLeft), isTrue);
|
||||
expect(slideRect.contains(rect.bottomRight), isTrue);
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'pie shows at most two series and keeps labels inside the slide',
|
||||
(tester) async {
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.pie,
|
||||
title: 'Veel gegevens',
|
||||
x: [
|
||||
'Een uitzonderlijk lang eerste label',
|
||||
'Een uitzonderlijk lang tweede label',
|
||||
'Een uitzonderlijk lang derde label',
|
||||
'Een uitzonderlijk lang vierde label',
|
||||
'Een uitzonderlijk lang vijfde label',
|
||||
'Een uitzonderlijk lang zesde label',
|
||||
],
|
||||
series: [
|
||||
ChartSeries(name: 'Een', data: [1, 2, 3, 4, 5, 6]),
|
||||
ChartSeries(name: 'Twee', data: [2, 3, 4, 5, 6, 7]),
|
||||
ChartSeries(name: 'Drie', data: [3, 4, 5, 6, 7, 8]),
|
||||
ChartSeries(name: 'Vier', data: [4, 5, 6, 7, 8, 9]),
|
||||
ChartSeries(name: 'Vijf', data: [5, 6, 7, 8, 9, 10]),
|
||||
ChartSeries(name: 'Zes', data: [6, 7, 8, 9, 10, 11]),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_host(spec));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(PieChart), findsNWidgets(2));
|
||||
expect(find.text('Drie'), findsNothing);
|
||||
final legendTop = tester.getTopLeft(find.text(spec.x.first)).dy;
|
||||
for (final chart in tester.widgetList<PieChart>(find.byType(PieChart))) {
|
||||
final box = tester.renderObject<RenderBox>(find.byWidget(chart));
|
||||
final bottom = box.localToGlobal(Offset(0, box.size.height)).dy;
|
||||
expect(bottom, lessThanOrEqualTo(legendTop));
|
||||
}
|
||||
final slideRect = tester.getRect(find.byType(SlidePreviewWidget));
|
||||
for (final label in spec.x) {
|
||||
final rect = tester.getRect(find.text(label));
|
||||
expect(slideRect.contains(rect.topLeft), isTrue);
|
||||
expect(slideRect.contains(rect.bottomRight), isTrue);
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,10 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
import 'package:ocideck/models/chart.dart';
|
||||
|
||||
void main() {
|
||||
test('chart palette starts with the EU flag colors', () {
|
||||
expect(chartColorPalette.take(2), ['#003399', '#FFCC00']);
|
||||
});
|
||||
|
||||
group('parseCsv', () {
|
||||
test('reads header series names and labelled rows', () {
|
||||
final (x, series) = parseCsv('\n, 2025, 2026\nQ1, 10, 12\nQ2, 14, 9\n');
|
||||
|
|
@ -24,16 +28,19 @@ void main() {
|
|||
type: ChartType.line,
|
||||
title: 'Omzet',
|
||||
x: ['Q1', 'Q2'],
|
||||
rowColors: ['#003399', '#FFCC00'],
|
||||
series: [
|
||||
ChartSeries(name: '2025', data: [10, 14]),
|
||||
ChartSeries(name: '2025', data: [10, 14], color: '#EF4444'),
|
||||
],
|
||||
);
|
||||
final back = ChartSpec.parse(spec.toBlock());
|
||||
expect(back.type, ChartType.line);
|
||||
expect(back.title, 'Omzet');
|
||||
expect(back.x, ['Q1', 'Q2']);
|
||||
expect(back.rowColors, ['#003399', '#FFCC00']);
|
||||
expect(back.series.single.name, '2025');
|
||||
expect(back.series.single.data, [10, 14]);
|
||||
expect(back.series.single.color, '#EF4444');
|
||||
expect(back.hasInlineData, isTrue);
|
||||
});
|
||||
|
||||
|
|
@ -43,13 +50,16 @@ void main() {
|
|||
title: 'Omzet',
|
||||
source: 'data/omzet.csv',
|
||||
x: ['Q1', 'Q2'],
|
||||
rowColors: ['#003399', '#FFCC00'],
|
||||
series: [
|
||||
ChartSeries(name: '2025', data: [10, 14]),
|
||||
ChartSeries(name: '2025', data: [10, 14], color: '#10B981'),
|
||||
],
|
||||
);
|
||||
final stored = ChartSpec.parse(spec.toBlock(forStorage: true));
|
||||
expect(stored.source, 'data/omzet.csv');
|
||||
expect(stored.hasInlineData, isFalse);
|
||||
expect(stored.rowColors, ['#003399', '#FFCC00']);
|
||||
expect(stored.series.single.color, '#10B981');
|
||||
|
||||
// The in-app/full form keeps the data.
|
||||
final full = ChartSpec.parse(spec.toBlock());
|
||||
|
|
@ -57,12 +67,35 @@ void main() {
|
|||
});
|
||||
|
||||
test('withCsv fills x/series and keeps the source', () {
|
||||
const spec = ChartSpec(type: ChartType.bar, source: 'data/o.csv');
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.bar,
|
||||
source: 'data/o.csv',
|
||||
rowColors: ['#003399', '#FFCC00'],
|
||||
series: [ChartSeries(name: 'oud', data: [], color: '#10B981')],
|
||||
);
|
||||
final filled = spec.withCsv(',A,B\nJan,1,2\nFeb,3,4');
|
||||
expect(filled.source, 'data/o.csv');
|
||||
expect(filled.x, ['Jan', 'Feb']);
|
||||
expect(filled.series.map((s) => s.name), ['A', 'B']);
|
||||
expect(filled.series[1].data, [2, 4]);
|
||||
expect(filled.series[0].color, '#10B981');
|
||||
expect(filled.series[1].color, isNull);
|
||||
expect(filled.rowColors, ['#003399', '#FFCC00']);
|
||||
});
|
||||
|
||||
test('invalid colors are ignored while valid colors are normalized', () {
|
||||
final valid = ChartSeries.fromJson({
|
||||
'name': 'A',
|
||||
'data': [1],
|
||||
'color': 'ef4444',
|
||||
});
|
||||
final invalid = ChartSeries.fromJson({
|
||||
'name': 'B',
|
||||
'data': [2],
|
||||
'color': 'red',
|
||||
});
|
||||
expect(valid.color, '#EF4444');
|
||||
expect(invalid.color, isNull);
|
||||
});
|
||||
|
||||
test('parse is tolerant of malformed JSON', () {
|
||||
|
|
@ -70,5 +103,32 @@ void main() {
|
|||
expect(spec.type, ChartType.bar);
|
||||
expect(spec.hasInlineData, isFalse);
|
||||
});
|
||||
|
||||
test('round-trips optional min/max bound lines for bar/line', () {
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.line,
|
||||
x: ['Q1'],
|
||||
series: [ChartSeries(name: 'A', data: [10])],
|
||||
minBound: 5,
|
||||
maxBound: 20,
|
||||
);
|
||||
final back = ChartSpec.parse(spec.toBlock());
|
||||
expect(back.minBound, 5);
|
||||
expect(back.maxBound, 20);
|
||||
});
|
||||
|
||||
test('bounds are dropped from a pie chart', () {
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.pie,
|
||||
x: ['Q1'],
|
||||
series: [ChartSeries(name: 'A', data: [10])],
|
||||
minBound: 5,
|
||||
maxBound: 20,
|
||||
);
|
||||
expect(spec.supportsBounds, isFalse);
|
||||
final back = ChartSpec.parse(spec.toBlock());
|
||||
expect(back.minBound, isNull);
|
||||
expect(back.maxBound, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/models/deck.dart';
|
||||
import 'package:ocideck/models/settings.dart';
|
||||
import 'package:ocideck/models/slide.dart';
|
||||
import 'package:ocideck/services/file_service.dart';
|
||||
|
|
@ -23,6 +26,36 @@ void main() {
|
|||
expect(n.state.isDirty, isTrue);
|
||||
});
|
||||
|
||||
test('loadDeck resolves a relative logo for an unsaved recovered deck', () {
|
||||
final temp = Directory.systemTemp.createTempSync(
|
||||
'ocideck_recovered_logo_test_',
|
||||
);
|
||||
addTearDown(() => temp.deleteSync(recursive: true));
|
||||
final logo = File('${temp.path}/logos/client.png')
|
||||
..createSync(recursive: true)
|
||||
..writeAsBytesSync([1, 2, 3]);
|
||||
|
||||
final md = MarkdownService();
|
||||
final file = FileService(
|
||||
md,
|
||||
ImageService(),
|
||||
() => const ThemeProfile(),
|
||||
homeDirectory: () => temp.path,
|
||||
);
|
||||
final notifier = DeckNotifier(md, file);
|
||||
notifier.loadDeck(
|
||||
Deck(
|
||||
title: 'Hersteld',
|
||||
themeProfile: const ThemeProfile(logoPath: 'logos/client.png'),
|
||||
slides: [Slide.create(SlideType.title)],
|
||||
),
|
||||
);
|
||||
|
||||
expect(notifier.state.filePath, isNull);
|
||||
expect(notifier.state.deck!.projectPath, isNull);
|
||||
expect(notifier.state.deck!.themeProfile.logoPath, logo.path);
|
||||
});
|
||||
|
||||
test('addSlide inserts right after the given index', () {
|
||||
final n = _notifier()..newDeck('D');
|
||||
n.addSlide(SlideType.bullets); // appended -> index 1
|
||||
|
|
|
|||
|
|
@ -37,4 +37,29 @@ void main() {
|
|||
expect(saved.themeProfile.logoPath, 'logos/client.png');
|
||||
expect(await File(p.join(temp.path, 'logos', 'client.png')).exists(), true);
|
||||
});
|
||||
|
||||
test(
|
||||
'current theme resolves a relative logo from the home directory',
|
||||
() async {
|
||||
final temp = await Directory.systemTemp.createTemp(
|
||||
'ocideck_theme_logo_test_',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await temp.exists()) await temp.delete(recursive: true);
|
||||
});
|
||||
|
||||
final logo = File(p.join(temp.path, 'logos', 'client.png'));
|
||||
await logo.parent.create(recursive: true);
|
||||
await logo.writeAsBytes([1, 2, 3]);
|
||||
|
||||
final service = FileService(
|
||||
MarkdownService(),
|
||||
ImageService(),
|
||||
() => const ThemeProfile(logoPath: 'logos/client.png'),
|
||||
homeDirectory: () => temp.path,
|
||||
);
|
||||
|
||||
expect(service.currentThemeProfile.logoPath, logo.path);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'dart:ui' as ui;
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/models/settings.dart';
|
||||
import 'package:ocideck/models/slide.dart';
|
||||
import 'package:ocideck/widgets/slides/slide_preview.dart';
|
||||
|
||||
|
|
@ -51,6 +52,30 @@ Future<({int width, int height, Uint8List bytes})> _capture(
|
|||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('a missing small logo does not overflow its bounds', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 800,
|
||||
child: SlidePreviewWidget(
|
||||
slide: Slide.create(SlideType.bullets),
|
||||
themeProfile: const ThemeProfile(
|
||||
logoPath: '/path/that/does/not/exist.png',
|
||||
logoSize: 36,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('twoImages paints both the left and right images', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
|
|||
|
|
@ -110,4 +110,89 @@ void main() {}
|
|||
expect(html, contains('data:font/ttf;base64,'));
|
||||
expect(html, contains("'EB Garamond'"));
|
||||
});
|
||||
|
||||
test('pie chart SVG renders every series and label', () {
|
||||
const slide = '''
|
||||
```chart
|
||||
{
|
||||
"type": "pie",
|
||||
"x": ["Team A", "Team B"],
|
||||
"series": [
|
||||
{"name": "Gereed", "color": "#10B981", "data": [70, 40]},
|
||||
{"name": "Open", "color": "#EF4444", "data": [30, 60]}
|
||||
]
|
||||
}
|
||||
```
|
||||
''';
|
||||
|
||||
final html = MarpHtmlService.renderChartBlocks(slide);
|
||||
|
||||
expect(html, contains('Team A'));
|
||||
expect(html, contains('Team B'));
|
||||
expect(html, contains('Gereed'));
|
||||
expect(html, contains('Open'));
|
||||
expect(html, contains('#003399'));
|
||||
expect(html, contains('#FFCC00'));
|
||||
});
|
||||
|
||||
test('pie chart SVG renders at most two series', () {
|
||||
const slide = '''
|
||||
```chart
|
||||
{
|
||||
"type": "pie",
|
||||
"x": ["A", "B"],
|
||||
"series": [
|
||||
{"name": "Een", "data": [1, 2]},
|
||||
{"name": "Twee", "data": [2, 3]},
|
||||
{"name": "Drie", "data": [3, 4]}
|
||||
]
|
||||
}
|
||||
```
|
||||
''';
|
||||
|
||||
final html = MarpHtmlService.renderChartBlocks(slide);
|
||||
|
||||
expect(html, contains('Een'));
|
||||
expect(html, contains('Twee'));
|
||||
expect(html, isNot(contains('Drie')));
|
||||
});
|
||||
|
||||
test('bar chart SVG draws optional min/max bound lines with labels', () {
|
||||
const slide = '''
|
||||
```chart
|
||||
{
|
||||
"type": "bar",
|
||||
"x": ["Q1", "Q2"],
|
||||
"series": [{"name": "Omzet", "data": [10, 14]}],
|
||||
"minBound": 5,
|
||||
"maxBound": 20
|
||||
}
|
||||
```
|
||||
''';
|
||||
|
||||
final html = MarpHtmlService.renderChartBlocks(slide);
|
||||
|
||||
expect(html, contains('stroke-dasharray'));
|
||||
expect(html, contains('min 5'));
|
||||
expect(html, contains('max 20'));
|
||||
});
|
||||
|
||||
test('pie chart SVG never draws bound lines', () {
|
||||
const slide = '''
|
||||
```chart
|
||||
{
|
||||
"type": "pie",
|
||||
"x": ["A", "B"],
|
||||
"series": [{"name": "Een", "data": [1, 2]}],
|
||||
"minBound": 5,
|
||||
"maxBound": 20
|
||||
}
|
||||
```
|
||||
''';
|
||||
|
||||
final html = MarpHtmlService.renderChartBlocks(slide);
|
||||
|
||||
expect(html, isNot(contains('stroke-dasharray')));
|
||||
expect(html, isNot(contains('min 5')));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue