feature/app-theming-and-code-slides #2
13 changed files with 1024 additions and 41 deletions
|
|
@ -2342,6 +2342,7 @@ const _dutchSourceStringAdditions = {
|
||||||
'Staaf': 'Bar',
|
'Staaf': 'Bar',
|
||||||
'Lijn': 'Line',
|
'Lijn': 'Line',
|
||||||
'Cirkel': 'Pie',
|
'Cirkel': 'Pie',
|
||||||
|
'Spider': 'Spider',
|
||||||
'CSV importeren': 'Import CSV',
|
'CSV importeren': 'Import CSV',
|
||||||
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)':
|
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)':
|
||||||
'Data (CSV: first row = series names, first column = labels)',
|
'Data (CSV: first row = series names, first column = labels)',
|
||||||
|
|
@ -2364,6 +2365,23 @@ const _dutchSourceStringAdditions = {
|
||||||
'Toepassen': 'Apply',
|
'Toepassen': 'Apply',
|
||||||
'Bij een cirkel worden maximaal de eerste twee reeksen getoond; de labels vormen de segmenten.':
|
'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.',
|
'Pie charts show at most the first two series; the labels form the slices.',
|
||||||
|
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
|
||||||
|
'A spider chart needs at least three labels (axes); each series forms a shape.',
|
||||||
|
'Een spider-diagram heeft minstens drie labels nodig':
|
||||||
|
'A spider chart needs at least three labels',
|
||||||
|
'Minimumlijn (optioneel)': 'Minimum line (optional)',
|
||||||
|
'Maximumlijn (optioneel)': 'Maximum line (optional)',
|
||||||
|
'Schaalminimum (optioneel)': 'Scale minimum (optional)',
|
||||||
|
'Schaalmaximum (optioneel)': 'Scale maximum (optional)',
|
||||||
|
'geen': 'none',
|
||||||
|
'Broncode achtergrond': 'Code background',
|
||||||
|
'Broncode tekst': 'Code text',
|
||||||
|
'Syntaxkleuring': 'Syntax colouring',
|
||||||
|
'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).':
|
||||||
|
'Off = everything in one colour (e.g. green on black for a CRT screen).',
|
||||||
|
'Eigen kleur (hex)': 'Custom colour (hex)',
|
||||||
|
'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.':
|
||||||
|
'For example #33FF33 for a CRT-green screen.',
|
||||||
'Platte tekst': 'Plain text',
|
'Platte tekst': 'Plain text',
|
||||||
'Titel (optioneel)': 'Title (optional)',
|
'Titel (optioneel)': 'Title (optional)',
|
||||||
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.':
|
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.':
|
||||||
|
|
@ -2382,6 +2400,24 @@ const _dutchSourceStringAdditions = {
|
||||||
},
|
},
|
||||||
'it': {
|
'it': {
|
||||||
'Annuleren': 'Annulla',
|
'Annuleren': 'Annulla',
|
||||||
|
'Spider': 'Radar',
|
||||||
|
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
|
||||||
|
'Un grafico radar richiede almeno tre etichette (assi); ogni serie forma una superficie.',
|
||||||
|
'Een spider-diagram heeft minstens drie labels nodig':
|
||||||
|
'Un grafico radar richiede almeno tre etichette',
|
||||||
|
'Minimumlijn (optioneel)': 'Linea minima (facoltativa)',
|
||||||
|
'Maximumlijn (optioneel)': 'Linea massima (facoltativa)',
|
||||||
|
'Schaalminimum (optioneel)': 'Scala minima (facoltativa)',
|
||||||
|
'Schaalmaximum (optioneel)': 'Scala massima (facoltativa)',
|
||||||
|
'geen': 'nessuno',
|
||||||
|
'Broncode achtergrond': 'Sfondo del codice',
|
||||||
|
'Broncode tekst': 'Testo del codice',
|
||||||
|
'Syntaxkleuring': 'Colorazione della sintassi',
|
||||||
|
'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).':
|
||||||
|
'Off = tutto in un solo colore (es. verde su nero per uno schermo CRT).',
|
||||||
|
'Eigen kleur (hex)': 'Colore personalizzato (hex)',
|
||||||
|
'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.':
|
||||||
|
'Ad esempio #33FF33 per uno schermo verde CRT.',
|
||||||
'Kleur van reeks': 'Colore della serie',
|
'Kleur van reeks': 'Colore della serie',
|
||||||
'Kleur van rij': 'Colore della riga',
|
'Kleur van rij': 'Colore della riga',
|
||||||
'Hexkleur': 'Colore esadecimale',
|
'Hexkleur': 'Colore esadecimale',
|
||||||
|
|
@ -2590,6 +2626,24 @@ const _dutchSourceStringAdditions = {
|
||||||
},
|
},
|
||||||
'de': {
|
'de': {
|
||||||
'Annuleren': 'Abbrechen',
|
'Annuleren': 'Abbrechen',
|
||||||
|
'Spider': 'Netz',
|
||||||
|
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
|
||||||
|
'Ein Netzdiagramm braucht mindestens drei Beschriftungen (Achsen); jede Reihe bildet eine Fläche.',
|
||||||
|
'Een spider-diagram heeft minstens drie labels nodig':
|
||||||
|
'Ein Netzdiagramm braucht mindestens drei Beschriftungen',
|
||||||
|
'Minimumlijn (optioneel)': 'Minimumlinie (optional)',
|
||||||
|
'Maximumlijn (optioneel)': 'Maximumlinie (optional)',
|
||||||
|
'Schaalminimum (optioneel)': 'Skalenminimum (optional)',
|
||||||
|
'Schaalmaximum (optioneel)': 'Skalenmaximum (optional)',
|
||||||
|
'geen': 'keine',
|
||||||
|
'Broncode achtergrond': 'Code-Hintergrund',
|
||||||
|
'Broncode tekst': 'Code-Text',
|
||||||
|
'Syntaxkleuring': 'Syntaxfärbung',
|
||||||
|
'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).':
|
||||||
|
'Aus = alles in einer Farbe (z. B. Grün auf Schwarz für einen CRT-Bildschirm).',
|
||||||
|
'Eigen kleur (hex)': 'Eigene Farbe (Hex)',
|
||||||
|
'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.':
|
||||||
|
'Zum Beispiel #33FF33 für einen CRT-grünen Bildschirm.',
|
||||||
'Kleur van reeks': 'Reihenfarbe',
|
'Kleur van reeks': 'Reihenfarbe',
|
||||||
'Kleur van rij': 'Zeilenfarbe',
|
'Kleur van rij': 'Zeilenfarbe',
|
||||||
'Hexkleur': 'Hex-Farbe',
|
'Hexkleur': 'Hex-Farbe',
|
||||||
|
|
@ -2799,6 +2853,24 @@ const _dutchSourceStringAdditions = {
|
||||||
},
|
},
|
||||||
'fr': {
|
'fr': {
|
||||||
'Annuleren': 'Annuler',
|
'Annuleren': 'Annuler',
|
||||||
|
'Spider': 'Radar',
|
||||||
|
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
|
||||||
|
'Un graphique radar nécessite au moins trois étiquettes (axes); chaque série forme une surface.',
|
||||||
|
'Een spider-diagram heeft minstens drie labels nodig':
|
||||||
|
'Un graphique radar nécessite au moins trois étiquettes',
|
||||||
|
'Minimumlijn (optioneel)': 'Ligne minimale (facultatif)',
|
||||||
|
'Maximumlijn (optioneel)': 'Ligne maximale (facultatif)',
|
||||||
|
'Schaalminimum (optioneel)': 'Échelle minimale (facultatif)',
|
||||||
|
'Schaalmaximum (optioneel)': 'Échelle maximale (facultatif)',
|
||||||
|
'geen': 'aucune',
|
||||||
|
'Broncode achtergrond': 'Fond du code',
|
||||||
|
'Broncode tekst': 'Texte du code',
|
||||||
|
'Syntaxkleuring': 'Coloration syntaxique',
|
||||||
|
'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).':
|
||||||
|
'Désactivé = tout en une seule couleur (p. ex. vert sur noir pour un écran CRT).',
|
||||||
|
'Eigen kleur (hex)': 'Couleur personnalisée (hex)',
|
||||||
|
'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.':
|
||||||
|
'Par exemple #33FF33 pour un écran vert CRT.',
|
||||||
'Kleur van reeks': 'Couleur de la série',
|
'Kleur van reeks': 'Couleur de la série',
|
||||||
'Kleur van rij': 'Couleur de la ligne',
|
'Kleur van rij': 'Couleur de la ligne',
|
||||||
'Hexkleur': 'Couleur hexadécimale',
|
'Hexkleur': 'Couleur hexadécimale',
|
||||||
|
|
@ -3008,6 +3080,24 @@ const _dutchSourceStringAdditions = {
|
||||||
},
|
},
|
||||||
'es': {
|
'es': {
|
||||||
'Annuleren': 'Cancelar',
|
'Annuleren': 'Cancelar',
|
||||||
|
'Spider': 'Radar',
|
||||||
|
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
|
||||||
|
'Un gráfico radar necesita al menos tres etiquetas (ejes); cada serie forma una superficie.',
|
||||||
|
'Een spider-diagram heeft minstens drie labels nodig':
|
||||||
|
'Un gráfico radar necesita al menos tres etiquetas',
|
||||||
|
'Minimumlijn (optioneel)': 'Línea mínima (opcional)',
|
||||||
|
'Maximumlijn (optioneel)': 'Línea máxima (opcional)',
|
||||||
|
'Schaalminimum (optioneel)': 'Escala mínima (opcional)',
|
||||||
|
'Schaalmaximum (optioneel)': 'Escala máxima (opcional)',
|
||||||
|
'geen': 'ninguno',
|
||||||
|
'Broncode achtergrond': 'Fondo del código',
|
||||||
|
'Broncode tekst': 'Texto del código',
|
||||||
|
'Syntaxkleuring': 'Coloreado de sintaxis',
|
||||||
|
'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).':
|
||||||
|
'Desactivado = todo en un solo color (p. ej. verde sobre negro para una pantalla CRT).',
|
||||||
|
'Eigen kleur (hex)': 'Color personalizado (hex)',
|
||||||
|
'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.':
|
||||||
|
'Por ejemplo #33FF33 para una pantalla verde CRT.',
|
||||||
'Kleur van reeks': 'Color de la serie',
|
'Kleur van reeks': 'Color de la serie',
|
||||||
'Kleur van rij': 'Color de la fila',
|
'Kleur van rij': 'Color de la fila',
|
||||||
'Hexkleur': 'Color hexadecimal',
|
'Hexkleur': 'Color hexadecimal',
|
||||||
|
|
@ -3217,6 +3307,24 @@ const _dutchSourceStringAdditions = {
|
||||||
},
|
},
|
||||||
'fy': {
|
'fy': {
|
||||||
'Annuleren': 'Annulearje',
|
'Annuleren': 'Annulearje',
|
||||||
|
'Spider': 'Spider',
|
||||||
|
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
|
||||||
|
'In spiderdiagram hat op syn minst trije labels (assen) nedich; eltse rige foarmet in flak.',
|
||||||
|
'Een spider-diagram heeft minstens drie labels nodig':
|
||||||
|
'In spiderdiagram hat op syn minst trije labels nedich',
|
||||||
|
'Minimumlijn (optioneel)': 'Minimumline (opsjoneel)',
|
||||||
|
'Maximumlijn (optioneel)': 'Maksimumline (opsjoneel)',
|
||||||
|
'Schaalminimum (optioneel)': 'Skaalminimum (opsjoneel)',
|
||||||
|
'Schaalmaximum (optioneel)': 'Skaalmaksimum (opsjoneel)',
|
||||||
|
'geen': 'gjin',
|
||||||
|
'Broncode achtergrond': 'Boarnekoade eftergrûn',
|
||||||
|
'Broncode tekst': 'Boarnekoade tekst',
|
||||||
|
'Syntaxkleuring': 'Syntakskleuring',
|
||||||
|
'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).':
|
||||||
|
'Ut = alles yn ien kleur (bgl. grien op swart foar in CRT-skerm).',
|
||||||
|
'Eigen kleur (hex)': 'Eigen kleur (hex)',
|
||||||
|
'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.':
|
||||||
|
'Bygelyks #33FF33 foar in CRT-grien skerm.',
|
||||||
'Kleur van reeks': 'Rigekleur',
|
'Kleur van reeks': 'Rigekleur',
|
||||||
'Kleur van rij': 'Rijekleur',
|
'Kleur van rij': 'Rijekleur',
|
||||||
'Hexkleur': 'Hekskleur',
|
'Hexkleur': 'Hekskleur',
|
||||||
|
|
@ -3423,6 +3531,24 @@ const _dutchSourceStringAdditions = {
|
||||||
},
|
},
|
||||||
'pap': {
|
'pap': {
|
||||||
'Annuleren': 'Kanselá',
|
'Annuleren': 'Kanselá',
|
||||||
|
'Spider': 'Radar',
|
||||||
|
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.':
|
||||||
|
'Un grafiko radar mester di por lo ménos tres etiketa (ehe); kada serie ta forma un superfisie.',
|
||||||
|
'Een spider-diagram heeft minstens drie labels nodig':
|
||||||
|
'Un grafiko radar mester di por lo ménos tres etiketa',
|
||||||
|
'Minimumlijn (optioneel)': 'Liña mínimo (opshonal)',
|
||||||
|
'Maximumlijn (optioneel)': 'Liña máksimo (opshonal)',
|
||||||
|
'Schaalminimum (optioneel)': 'Eskala mínimo (opshonal)',
|
||||||
|
'Schaalmaximum (optioneel)': 'Eskala máksimo (opshonal)',
|
||||||
|
'geen': 'niun',
|
||||||
|
'Broncode achtergrond': 'Fondo di kódigo',
|
||||||
|
'Broncode tekst': 'Teksto di kódigo',
|
||||||
|
'Syntaxkleuring': 'Koloreashon di sintaksis',
|
||||||
|
'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).':
|
||||||
|
'Apagá = tur kos den un solo koló (p.e. berde riba pretu pa un pantaya CRT).',
|
||||||
|
'Eigen kleur (hex)': 'Koló propio (hex)',
|
||||||
|
'Bijvoorbeeld #33FF33 voor een CRT-groen scherm.':
|
||||||
|
'Por ehèmpel #33FF33 pa un pantaya berde CRT.',
|
||||||
'Kleur van reeks': 'Koló di serie',
|
'Kleur van reeks': 'Koló di serie',
|
||||||
'Kleur van rij': 'Koló di liña',
|
'Kleur van rij': 'Koló di liña',
|
||||||
'Hexkleur': 'Koló hexadecimal',
|
'Hexkleur': 'Koló hexadecimal',
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const List<String> chartColorPalette = [
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Supported chart kinds for a chart slide.
|
/// Supported chart kinds for a chart slide.
|
||||||
enum ChartType { bar, line, pie }
|
enum ChartType { bar, line, pie, radar }
|
||||||
|
|
||||||
ChartType _chartTypeFromName(String? name) => ChartType.values.firstWhere(
|
ChartType _chartTypeFromName(String? name) => ChartType.values.firstWhere(
|
||||||
(t) => t.name == name,
|
(t) => t.name == name,
|
||||||
|
|
@ -100,9 +100,15 @@ class ChartSpec {
|
||||||
|
|
||||||
bool get hasInlineData => x.isNotEmpty && series.isNotEmpty;
|
bool get hasInlineData => x.isNotEmpty && series.isNotEmpty;
|
||||||
|
|
||||||
/// Bounds only apply to bar/line charts; a pie spec never shows them.
|
/// Whether the optional [minBound]/[maxBound] apply. On bar/line they are
|
||||||
|
/// horizontal threshold lines; on radar they fix the scale (centre/outer
|
||||||
|
/// ring). Pie charts have no axis, so they never use bounds.
|
||||||
bool get supportsBounds => type != ChartType.pie;
|
bool get supportsBounds => type != ChartType.pie;
|
||||||
|
|
||||||
|
/// True only where bounds render as horizontal threshold *lines*.
|
||||||
|
bool get supportsBoundLines =>
|
||||||
|
type == ChartType.bar || type == ChartType.line;
|
||||||
|
|
||||||
ChartSpec copyWith({
|
ChartSpec copyWith({
|
||||||
ChartType? type,
|
ChartType? type,
|
||||||
String? title,
|
String? title,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,17 @@ class ThemeProfile {
|
||||||
final String titleBackgroundColor;
|
final String titleBackgroundColor;
|
||||||
final String titleTextColor;
|
final String titleTextColor;
|
||||||
final String sectionBackgroundColor;
|
final String sectionBackgroundColor;
|
||||||
|
|
||||||
|
/// Colours for code (broncode) slides. Defaults mirror the atom-one-dark
|
||||||
|
/// editor look. Set e.g. black background + bright green text with
|
||||||
|
/// [codeHighlightSyntax] off for a classic CRT terminal feel.
|
||||||
|
final String codeBackgroundColor;
|
||||||
|
final String codeTextColor;
|
||||||
|
|
||||||
|
/// When false, code is shown monochrome in [codeTextColor] (no per-token
|
||||||
|
/// syntax colours) — required for a believable single-colour CRT screen.
|
||||||
|
final bool codeHighlightSyntax;
|
||||||
|
|
||||||
final String? logoPath;
|
final String? logoPath;
|
||||||
final String logoPosition;
|
final String logoPosition;
|
||||||
final int logoSize;
|
final int logoSize;
|
||||||
|
|
@ -40,6 +51,9 @@ class ThemeProfile {
|
||||||
this.titleBackgroundColor = '#1C2B47',
|
this.titleBackgroundColor = '#1C2B47',
|
||||||
this.titleTextColor = '#FFFFFF',
|
this.titleTextColor = '#FFFFFF',
|
||||||
this.sectionBackgroundColor = '#2E7D64',
|
this.sectionBackgroundColor = '#2E7D64',
|
||||||
|
this.codeBackgroundColor = '#282C34',
|
||||||
|
this.codeTextColor = '#ABB2BF',
|
||||||
|
this.codeHighlightSyntax = true,
|
||||||
this.logoPath,
|
this.logoPath,
|
||||||
this.logoPosition = 'bottom-right',
|
this.logoPosition = 'bottom-right',
|
||||||
this.logoSize = 96,
|
this.logoSize = 96,
|
||||||
|
|
@ -70,6 +84,9 @@ class ThemeProfile {
|
||||||
String? titleBackgroundColor,
|
String? titleBackgroundColor,
|
||||||
String? titleTextColor,
|
String? titleTextColor,
|
||||||
String? sectionBackgroundColor,
|
String? sectionBackgroundColor,
|
||||||
|
String? codeBackgroundColor,
|
||||||
|
String? codeTextColor,
|
||||||
|
bool? codeHighlightSyntax,
|
||||||
String? logoPath,
|
String? logoPath,
|
||||||
String? logoPosition,
|
String? logoPosition,
|
||||||
int? logoSize,
|
int? logoSize,
|
||||||
|
|
@ -92,6 +109,9 @@ class ThemeProfile {
|
||||||
titleTextColor: titleTextColor ?? this.titleTextColor,
|
titleTextColor: titleTextColor ?? this.titleTextColor,
|
||||||
sectionBackgroundColor:
|
sectionBackgroundColor:
|
||||||
sectionBackgroundColor ?? this.sectionBackgroundColor,
|
sectionBackgroundColor ?? this.sectionBackgroundColor,
|
||||||
|
codeBackgroundColor: codeBackgroundColor ?? this.codeBackgroundColor,
|
||||||
|
codeTextColor: codeTextColor ?? this.codeTextColor,
|
||||||
|
codeHighlightSyntax: codeHighlightSyntax ?? this.codeHighlightSyntax,
|
||||||
logoPath: clearLogo ? null : (logoPath ?? this.logoPath),
|
logoPath: clearLogo ? null : (logoPath ?? this.logoPath),
|
||||||
logoPosition: logoPosition ?? this.logoPosition,
|
logoPosition: logoPosition ?? this.logoPosition,
|
||||||
logoSize: logoSize ?? this.logoSize,
|
logoSize: logoSize ?? this.logoSize,
|
||||||
|
|
@ -116,6 +136,9 @@ class ThemeProfile {
|
||||||
'titleBackgroundColor': titleBackgroundColor,
|
'titleBackgroundColor': titleBackgroundColor,
|
||||||
'titleTextColor': titleTextColor,
|
'titleTextColor': titleTextColor,
|
||||||
'sectionBackgroundColor': sectionBackgroundColor,
|
'sectionBackgroundColor': sectionBackgroundColor,
|
||||||
|
'codeBackgroundColor': codeBackgroundColor,
|
||||||
|
'codeTextColor': codeTextColor,
|
||||||
|
'codeHighlightSyntax': codeHighlightSyntax,
|
||||||
'logoPath': logoPath,
|
'logoPath': logoPath,
|
||||||
'logoPosition': logoPosition,
|
'logoPosition': logoPosition,
|
||||||
'logoSize': logoSize,
|
'logoSize': logoSize,
|
||||||
|
|
@ -146,6 +169,10 @@ class ThemeProfile {
|
||||||
titleTextColor: json['titleTextColor'] as String? ?? '#FFFFFF',
|
titleTextColor: json['titleTextColor'] as String? ?? '#FFFFFF',
|
||||||
sectionBackgroundColor:
|
sectionBackgroundColor:
|
||||||
json['sectionBackgroundColor'] as String? ?? '#2E7D64',
|
json['sectionBackgroundColor'] as String? ?? '#2E7D64',
|
||||||
|
codeBackgroundColor:
|
||||||
|
json['codeBackgroundColor'] as String? ?? '#282C34',
|
||||||
|
codeTextColor: json['codeTextColor'] as String? ?? '#ABB2BF',
|
||||||
|
codeHighlightSyntax: json['codeHighlightSyntax'] as bool? ?? true,
|
||||||
logoPath: json['logoPath'] as String?,
|
logoPath: json['logoPath'] as String?,
|
||||||
logoPosition: json['logoPosition'] as String? ?? 'bottom-right',
|
logoPosition: json['logoPosition'] as String? ?? 'bottom-right',
|
||||||
logoSize: (json['logoSize'] as num?)?.round() ?? 96,
|
logoSize: (json['logoSize'] as num?)?.round() ?? 96,
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,8 @@ class MarpHtmlService {
|
||||||
case ChartType.pie:
|
case ChartType.pie:
|
||||||
final legendRows = (spec.x.length / 6).ceil().clamp(1, 3);
|
final legendRows = (spec.x.length / 6).ceil().clamp(1, 3);
|
||||||
_pieSvg(b, spec, plotTop, theme, bottom: 398 - (legendRows - 1) * 28);
|
_pieSvg(b, spec, plotTop, theme, bottom: 398 - (legendRows - 1) * 28);
|
||||||
|
case ChartType.radar:
|
||||||
|
_radarSvg(b, spec, plotTop, theme, textColor);
|
||||||
}
|
}
|
||||||
if (spec.type == ChartType.pie) {
|
if (spec.type == ChartType.pie) {
|
||||||
_pieLegendSvg(b, spec, textColor);
|
_pieLegendSvg(b, spec, textColor);
|
||||||
|
|
@ -269,7 +271,7 @@ class MarpHtmlService {
|
||||||
double bottom,
|
double bottom,
|
||||||
double maxY,
|
double maxY,
|
||||||
) {
|
) {
|
||||||
if (!spec.supportsBounds) return;
|
if (!spec.supportsBoundLines) return;
|
||||||
void draw(double? value, String color, String prefix) {
|
void draw(double? value, String color, String prefix) {
|
||||||
if (value == null || value < 0 || value > maxY) return;
|
if (value == null || value < 0 || value > maxY) return;
|
||||||
final y = bottom - (bottom - top) * (value / maxY);
|
final y = bottom - (bottom - top) * (value / maxY);
|
||||||
|
|
@ -448,6 +450,129 @@ class MarpHtmlService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void _radarSvg(
|
||||||
|
StringBuffer b,
|
||||||
|
ChartSpec spec,
|
||||||
|
double top,
|
||||||
|
ThemeProfile? theme,
|
||||||
|
String textColor,
|
||||||
|
) {
|
||||||
|
final n = spec.x.length;
|
||||||
|
if (n < 3 || spec.series.isEmpty) return;
|
||||||
|
const bottom = 382.0;
|
||||||
|
const cx = 400.0;
|
||||||
|
final cy = (top + bottom) / 2;
|
||||||
|
final radius = math.min(170.0, (bottom - top) / 2 - 34);
|
||||||
|
final (lo, hi, ticks) = _radarScale(spec);
|
||||||
|
final span = (hi - lo) == 0 ? 1.0 : (hi - lo);
|
||||||
|
double angle(int i) => -math.pi / 2 + 2 * math.pi * i / n;
|
||||||
|
|
||||||
|
// Concentric grid rings, evenly spaced across the [lo, hi] scale.
|
||||||
|
for (var ring = 1; ring <= ticks; ring++) {
|
||||||
|
final rr = radius * ring / ticks;
|
||||||
|
final pts = [
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
'${cx + rr * math.cos(angle(i))},${cy + rr * math.sin(angle(i))}',
|
||||||
|
].join(' ');
|
||||||
|
b.write(
|
||||||
|
'<polygon points="$pts" fill="none" stroke="#e2e8f0" '
|
||||||
|
'stroke-width="1"/>',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale labels up the top spoke: lo at the centre, hi at the outer ring.
|
||||||
|
for (var k = 0; k <= ticks; k++) {
|
||||||
|
final value = lo + span * k / ticks;
|
||||||
|
final y = cy - radius * k / ticks;
|
||||||
|
b.write(
|
||||||
|
'<text x="${cx + 6}" y="${y - 2}" font-size="11" '
|
||||||
|
'fill="#94a3b8">${_num(value)}</text>',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spokes and axis labels.
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
final c = math.cos(angle(i));
|
||||||
|
final s = math.sin(angle(i));
|
||||||
|
b.write(
|
||||||
|
'<line x1="$cx" y1="$cy" x2="${cx + radius * c}" '
|
||||||
|
'y2="${cy + radius * s}" stroke="#e2e8f0" stroke-width="1"/>',
|
||||||
|
);
|
||||||
|
final anchor = c > 0.3 ? 'start' : (c < -0.3 ? 'end' : 'middle');
|
||||||
|
final label = spec.x[i].length > 12
|
||||||
|
? '${spec.x[i].substring(0, 11)}…'
|
||||||
|
: spec.x[i];
|
||||||
|
b.write(
|
||||||
|
'<text x="${cx + (radius + 18) * c}" y="${cy + (radius + 18) * s + 4}" '
|
||||||
|
'text-anchor="$anchor" font-size="13" fill="$textColor">'
|
||||||
|
'${_esc(label)}</text>',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One filled polygon per series.
|
||||||
|
for (var si = 0; si < spec.series.length; si++) {
|
||||||
|
final data = spec.series[si].data;
|
||||||
|
final pts = [
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
() {
|
||||||
|
final v = i < data.length ? data[i] : 0.0;
|
||||||
|
final rr = radius * ((v - lo) / span).clamp(0.0, 1.0);
|
||||||
|
return '${cx + rr * math.cos(angle(i))},'
|
||||||
|
'${cy + rr * math.sin(angle(i))}';
|
||||||
|
}(),
|
||||||
|
].join(' ');
|
||||||
|
final color = _color(spec, si, theme);
|
||||||
|
b.write(
|
||||||
|
'<polygon points="$pts" fill="$color" fill-opacity="0.16" '
|
||||||
|
'stroke="$color" stroke-width="3" stroke-linejoin="round"/>',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Radar scale shared with the live preview: honour optional min/max bounds,
|
||||||
|
/// otherwise round the data range to a tidy scale with an even tick count.
|
||||||
|
static (double, double, int) _radarScale(ChartSpec spec) {
|
||||||
|
var dataMin = 0.0;
|
||||||
|
var dataMax = 0.0;
|
||||||
|
var seen = false;
|
||||||
|
for (final s in spec.series) {
|
||||||
|
for (final v in s.data) {
|
||||||
|
if (!seen) {
|
||||||
|
dataMin = v;
|
||||||
|
dataMax = v;
|
||||||
|
seen = true;
|
||||||
|
} else {
|
||||||
|
if (v < dataMin) dataMin = v;
|
||||||
|
if (v > dataMax) dataMax = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!seen) {
|
||||||
|
dataMin = 0;
|
||||||
|
dataMax = 1;
|
||||||
|
}
|
||||||
|
final rawLo = spec.minBound ?? (dataMin < 0 ? dataMin : 0);
|
||||||
|
final rawHi = spec.maxBound ?? dataMax;
|
||||||
|
final range = (rawHi - rawLo).abs();
|
||||||
|
final r = range <= 0 ? 1.0 : range;
|
||||||
|
final rawStep = r / 4;
|
||||||
|
final mag = math.pow(10, (math.log(rawStep) / math.ln10).floor()).toDouble();
|
||||||
|
final norm = rawStep / mag;
|
||||||
|
final niceNorm = norm < 1.5
|
||||||
|
? 1.0
|
||||||
|
: norm < 3
|
||||||
|
? 2.0
|
||||||
|
: norm < 7
|
||||||
|
? 5.0
|
||||||
|
: 10.0;
|
||||||
|
final step = niceNorm * mag;
|
||||||
|
final lo = spec.minBound ?? (rawLo / step).floorToDouble() * step;
|
||||||
|
var hi = spec.maxBound ?? (rawHi / step).ceilToDouble() * step;
|
||||||
|
if (hi <= lo) hi = lo + step;
|
||||||
|
final ticks = math.max(2, ((hi - lo) / step).round());
|
||||||
|
return (lo, hi, ticks);
|
||||||
|
}
|
||||||
|
|
||||||
static String _shortChartLabel(String value) =>
|
static String _shortChartLabel(String value) =>
|
||||||
value.length > 13 ? '${value.substring(0, 12)}…' : value;
|
value.length > 13 ? '${value.substring(0, 12)}…' : value;
|
||||||
|
|
||||||
|
|
@ -469,8 +594,10 @@ class MarpHtmlService {
|
||||||
'.slide h2{font-size:34px;margin:.15em 0;color:${t.accentColor}}'
|
'.slide h2{font-size:34px;margin:.15em 0;color:${t.accentColor}}'
|
||||||
'.slide a{color:${t.accentColor}}'
|
'.slide a{color:${t.accentColor}}'
|
||||||
'.slide p,.slide li{font-size:24px;line-height:1.45}'
|
'.slide p,.slide li{font-size:24px;line-height:1.45}'
|
||||||
'.slide pre{background:#f6f8fa;border:1px solid #e1e4e8;border-radius:6px;'
|
'.slide pre{background:${t.codeBackgroundColor};color:${t.codeTextColor};'
|
||||||
|
'border:1px solid ${t.codeTextColor}38;border-radius:6px;'
|
||||||
'padding:16px;overflow:auto;font-size:18px}'
|
'padding:16px;overflow:auto;font-size:18px}'
|
||||||
|
'.slide pre code{color:${t.codeTextColor};background:transparent}'
|
||||||
'.slide code{font-family:SFMono-Regular,Consolas,"Liberation Mono",monospace}'
|
'.slide code{font-family:SFMono-Regular,Consolas,"Liberation Mono",monospace}'
|
||||||
'.slide pre.mermaid{background:transparent;border:0;text-align:center}'
|
'.slide pre.mermaid{background:transparent;border:0;text-align:center}'
|
||||||
'.slide img{max-width:100%}'
|
'.slide img{max-width:100%}'
|
||||||
|
|
|
||||||
|
|
@ -939,6 +939,41 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
(v) =>
|
(v) =>
|
||||||
_themeProfile = _themeProfile.copyWith(sectionBackgroundColor: v),
|
_themeProfile = _themeProfile.copyWith(sectionBackgroundColor: v),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_sectionTitle(l10n.d('Broncode')),
|
||||||
|
_colorSetting(
|
||||||
|
l10n.d('Broncode achtergrond'),
|
||||||
|
_themeProfile.codeBackgroundColor,
|
||||||
|
(v) =>
|
||||||
|
_themeProfile = _themeProfile.copyWith(codeBackgroundColor: v),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_colorSetting(
|
||||||
|
l10n.d('Broncode tekst'),
|
||||||
|
_themeProfile.codeTextColor,
|
||||||
|
(v) => _themeProfile = _themeProfile.copyWith(codeTextColor: v),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
SwitchListTile(
|
||||||
|
value: _themeProfile.codeHighlightSyntax,
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
_themeProfile = _themeProfile.copyWith(codeHighlightSyntax: v);
|
||||||
|
_profileTouched = true;
|
||||||
|
}),
|
||||||
|
title: Text(
|
||||||
|
l10n.d('Syntaxkleuring'),
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
l10n.d(
|
||||||
|
'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).',
|
||||||
|
),
|
||||||
|
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
_stylePreview(),
|
_stylePreview(),
|
||||||
],
|
],
|
||||||
|
|
@ -1149,18 +1184,143 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
for (final color in _colorPresets)
|
for (final color in _colorPresets)
|
||||||
_colorSwatch(
|
_colorSwatch(
|
||||||
color,
|
color,
|
||||||
selected: value == color,
|
selected: value.toUpperCase() == color,
|
||||||
onTap: () => setState(() {
|
onTap: () => setState(() {
|
||||||
onChanged(color);
|
onChanged(color);
|
||||||
_profileTouched = true;
|
_profileTouched = true;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
// Show the current value as a selected swatch when it isn't one of
|
||||||
|
// the presets (e.g. a hand-entered CRT green).
|
||||||
|
if (!_colorPresets.contains(value.toUpperCase()))
|
||||||
|
_colorSwatch(
|
||||||
|
value,
|
||||||
|
selected: true,
|
||||||
|
onTap: () => _editCustomColor(value, onChanged),
|
||||||
|
),
|
||||||
|
_customColorButton(value, onChanged),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _customColorButton(String value, ValueChanged<String> onChanged) {
|
||||||
|
return Tooltip(
|
||||||
|
message: context.l10n.d('Eigen kleur (hex)'),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _editCustomColor(value, onChanged),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: const Color(0xFFCBD5E1)),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.tune,
|
||||||
|
size: 18,
|
||||||
|
color: Color(0xFF64748B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editCustomColor(
|
||||||
|
String initial,
|
||||||
|
ValueChanged<String> onChanged,
|
||||||
|
) async {
|
||||||
|
final picked = await _pickHexColor(initial);
|
||||||
|
if (picked == null || !mounted) return;
|
||||||
|
setState(() {
|
||||||
|
onChanged(picked);
|
||||||
|
_profileTouched = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _pickHexColor(String initial) {
|
||||||
|
final controller = TextEditingController(text: initial);
|
||||||
|
String? normalize(String raw) {
|
||||||
|
final up = raw.trim().toUpperCase();
|
||||||
|
final hex = up.startsWith('#') ? up : '#$up';
|
||||||
|
return RegExp(r'^#[0-9A-F]{6}$').hasMatch(hex) ? hex : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final l10n = context.l10n;
|
||||||
|
return showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) {
|
||||||
|
final normalized = normalize(controller.text);
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(l10n.d('Eigen kleur (hex)')),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _parseColor(normalized ?? '#FFFFFF'),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: const Color(0xFFCBD5E1)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.d('Hexkleur'),
|
||||||
|
hintText: '#33FF33',
|
||||||
|
isDense: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(
|
||||||
|
RegExp(r'[#0-9a-fA-F]'),
|
||||||
|
),
|
||||||
|
LengthLimitingTextInputFormatter(7),
|
||||||
|
],
|
||||||
|
onChanged: (_) => setDialogState(() {}),
|
||||||
|
onSubmitted: (_) {
|
||||||
|
final ok = normalize(controller.text);
|
||||||
|
if (ok != null) Navigator.pop(context, ok);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.d('Bijvoorbeeld #33FF33 voor een CRT-groen scherm.'),
|
||||||
|
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(l10n.d('Annuleren')),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: normalized == null
|
||||||
|
? null
|
||||||
|
: () => Navigator.pop(context, normalized),
|
||||||
|
child: Text(l10n.d('Toepassen')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).whenComplete(controller.dispose);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _colorSwatch(
|
Widget _colorSwatch(
|
||||||
String color, {
|
String color, {
|
||||||
required bool selected,
|
required bool selected,
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ class _ChartEditorState extends State<ChartEditor> {
|
||||||
_loadFromSpec(spec);
|
_loadFromSpec(spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get _supportsBounds => _type != ChartType.pie;
|
||||||
|
|
||||||
static String _fmtBound(double? v) => v == null ? '' : _fmt(v);
|
static String _fmtBound(double? v) => v == null ? '' : _fmt(v);
|
||||||
|
|
||||||
static double? _parseBound(String raw) {
|
static double? _parseBound(String raw) {
|
||||||
|
|
@ -133,8 +135,8 @@ class _ChartEditorState extends State<ChartEditor> {
|
||||||
x: List<String>.from(_xLabels),
|
x: List<String>.from(_xLabels),
|
||||||
rowColors: List<String?>.from(_rowColors),
|
rowColors: List<String?>.from(_rowColors),
|
||||||
series: series,
|
series: series,
|
||||||
minBound: _type == ChartType.pie ? null : _parseBound(_minBound.text),
|
minBound: _supportsBounds ? _parseBound(_minBound.text) : null,
|
||||||
maxBound: _type == ChartType.pie ? null : _parseBound(_maxBound.text),
|
maxBound: _supportsBounds ? _parseBound(_maxBound.text) : null,
|
||||||
);
|
);
|
||||||
widget.onUpdate(widget.slide.copyWith(customMarkdown: spec.toBlock()));
|
widget.onUpdate(widget.slide.copyWith(customMarkdown: spec.toBlock()));
|
||||||
}
|
}
|
||||||
|
|
@ -467,6 +469,10 @@ class _ChartEditorState extends State<ChartEditor> {
|
||||||
value: ChartType.pie,
|
value: ChartType.pie,
|
||||||
child: Text(l10n.d('Cirkel')),
|
child: Text(l10n.d('Cirkel')),
|
||||||
),
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: ChartType.radar,
|
||||||
|
child: Text(l10n.d('Spider')),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
if (v == null) return;
|
if (v == null) return;
|
||||||
|
|
@ -492,7 +498,17 @@ class _ChartEditorState extends State<ChartEditor> {
|
||||||
style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)),
|
style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_type != ChartType.pie)
|
if (_type == ChartType.radar)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Text(
|
||||||
|
l10n.d(
|
||||||
|
'Een spider-diagram heeft minstens drie labels (assen) nodig; elke reeks vormt een vlak.',
|
||||||
|
),
|
||||||
|
style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_supportsBounds)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 12),
|
padding: const EdgeInsets.only(top: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
@ -502,7 +518,9 @@ class _ChartEditorState extends State<ChartEditor> {
|
||||||
child: _boundField(
|
child: _boundField(
|
||||||
key: const ValueKey('chart-min-bound'),
|
key: const ValueKey('chart-min-bound'),
|
||||||
controller: _minBound,
|
controller: _minBound,
|
||||||
label: l10n.d('Minimumlijn (optioneel)'),
|
label: _type == ChartType.radar
|
||||||
|
? l10n.d('Schaalminimum (optioneel)')
|
||||||
|
: l10n.d('Minimumlijn (optioneel)'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
@ -510,7 +528,9 @@ class _ChartEditorState extends State<ChartEditor> {
|
||||||
child: _boundField(
|
child: _boundField(
|
||||||
key: const ValueKey('chart-max-bound'),
|
key: const ValueKey('chart-max-bound'),
|
||||||
controller: _maxBound,
|
controller: _maxBound,
|
||||||
label: l10n.d('Maximumlijn (optioneel)'),
|
label: _type == ChartType.radar
|
||||||
|
? l10n.d('Schaalmaximum (optioneel)')
|
||||||
|
: l10n.d('Maximumlijn (optioneel)'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -2095,21 +2095,33 @@ class _CodePreview extends StatelessWidget {
|
||||||
final lang = slide.codeLanguage.trim();
|
final lang = slide.codeLanguage.trim();
|
||||||
final known = lang.isNotEmpty && allLanguages.containsKey(lang);
|
final known = lang.isNotEmpty && allLanguages.containsKey(lang);
|
||||||
|
|
||||||
|
final codeBg = _hexColor(profile.codeBackgroundColor);
|
||||||
|
final codeFg = _hexColor(profile.codeTextColor);
|
||||||
|
|
||||||
final mono = TextStyle(
|
final mono = TextStyle(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
|
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
|
||||||
fontSize: w * 0.024,
|
fontSize: w * 0.024,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
color: const Color(0xFFABB2BF), // atom-one-dark voorgrond
|
color: codeFg,
|
||||||
);
|
);
|
||||||
|
|
||||||
// HighlightView gooit een fout bij een onbekende taal; daarom vallen we
|
// HighlightView throws on an unknown language, so fall back to plain (but
|
||||||
// dan terug op platte (maar wel monospace) tekst.
|
// monospace) text. When syntax highlighting is off we always render plain
|
||||||
final Widget codeContent = known
|
// text so the whole block is one colour — needed for a CRT-green screen.
|
||||||
|
final Widget codeContent = (known && profile.codeHighlightSyntax)
|
||||||
? HighlightView(
|
? HighlightView(
|
||||||
code,
|
code,
|
||||||
language: lang,
|
language: lang,
|
||||||
theme: atomOneDarkTheme,
|
// Keep atom-one-dark's per-token colours but drop its own
|
||||||
|
// background so our themed [codeBg] shows through unchanged.
|
||||||
|
theme: {
|
||||||
|
...atomOneDarkTheme,
|
||||||
|
'root': (atomOneDarkTheme['root'] ?? const TextStyle()).copyWith(
|
||||||
|
backgroundColor: codeBg,
|
||||||
|
color: codeFg,
|
||||||
|
),
|
||||||
|
},
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
textStyle: mono,
|
textStyle: mono,
|
||||||
)
|
)
|
||||||
|
|
@ -2127,9 +2139,9 @@ class _CodePreview extends StatelessWidget {
|
||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF282C34), // atom-one-dark achtergrond
|
color: codeBg,
|
||||||
borderRadius: BorderRadius.circular(w * 0.012),
|
borderRadius: BorderRadius.circular(w * 0.012),
|
||||||
border: Border.all(color: const Color(0xFF3A3F4B)),
|
border: Border.all(color: codeFg.withValues(alpha: 0.22)),
|
||||||
),
|
),
|
||||||
padding: EdgeInsets.all(w * 0.03),
|
padding: EdgeInsets.all(w * 0.03),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -2144,7 +2156,7 @@ class _CodePreview extends StatelessWidget {
|
||||||
TextStyle(
|
TextStyle(
|
||||||
fontSize: w * 0.03,
|
fontSize: w * 0.03,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: const Color(0xFFE5E7EB),
|
color: codeFg,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
linkColor: _hexColor(profile.accentColor),
|
linkColor: _hexColor(profile.accentColor),
|
||||||
|
|
@ -2475,6 +2487,8 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
||||||
return _lineChart(spec, textColor);
|
return _lineChart(spec, textColor);
|
||||||
case ChartType.pie:
|
case ChartType.pie:
|
||||||
return _pieChart(spec, textColor);
|
return _pieChart(spec, textColor);
|
||||||
|
case ChartType.radar:
|
||||||
|
return _radarChart(spec, textColor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2511,7 +2525,7 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
||||||
|
|
||||||
/// Optional min/max threshold lines drawn across the plot (bar/line only).
|
/// Optional min/max threshold lines drawn across the plot (bar/line only).
|
||||||
ExtraLinesData _boundLines(ChartSpec spec) {
|
ExtraLinesData _boundLines(ChartSpec spec) {
|
||||||
if (!spec.supportsBounds) return const ExtraLinesData();
|
if (!spec.supportsBoundLines) return const ExtraLinesData();
|
||||||
final dash = [
|
final dash = [
|
||||||
(w * 0.018).round().clamp(4, 14),
|
(w * 0.018).round().clamp(4, 14),
|
||||||
(w * 0.01).round().clamp(3, 9),
|
(w * 0.01).round().clamp(3, 9),
|
||||||
|
|
@ -2729,6 +2743,10 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
||||||
extraLinesData: _boundLines(spec),
|
extraLinesData: _boundLines(spec),
|
||||||
lineTouchData: LineTouchData(
|
lineTouchData: LineTouchData(
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
// Measure proximity to the actual dot (x *and* y), not just the
|
||||||
|
// column, so the tooltip belongs to the point under the cursor.
|
||||||
|
distanceCalculator: (touch, spot) => (touch - spot).distance,
|
||||||
|
touchSpotThreshold: (w * 0.02).clamp(8.0, 24.0).toDouble(),
|
||||||
mouseCursorResolver: (event, response) =>
|
mouseCursorResolver: (event, response) =>
|
||||||
response?.lineBarSpots?.isEmpty ?? true
|
response?.lineBarSpots?.isEmpty ?? true
|
||||||
? SystemMouseCursors.basic
|
? SystemMouseCursors.basic
|
||||||
|
|
@ -2737,30 +2755,18 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
||||||
fitInsideHorizontally: true,
|
fitInsideHorizontally: true,
|
||||||
fitInsideVertically: true,
|
fitInsideVertically: true,
|
||||||
getTooltipColor: (_) => const Color(0xFF0F172A),
|
getTooltipColor: (_) => const Color(0xFF0F172A),
|
||||||
|
// Show every dot near the cursor. When several dots sit on (almost)
|
||||||
|
// the same spot they all appear; the font shrinks to keep them
|
||||||
|
// readable when stacked.
|
||||||
getTooltipItems: (spots) {
|
getTooltipItems: (spots) {
|
||||||
// When several series cross the same x, fl_chart hands us one
|
final style = _lineTooltipStyle(spots.length);
|
||||||
// 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 [
|
return [
|
||||||
for (var k = 0; k < spots.length; k++)
|
for (final spot in spots)
|
||||||
if (k != nearest)
|
LineTooltipItem(
|
||||||
null
|
'${spot.spotIndex < spec.x.length ? spec.x[spot.spotIndex] : ''}\n'
|
||||||
else
|
'${spot.barIndex < spec.series.length && spec.series[spot.barIndex].name.isNotEmpty ? spec.series[spot.barIndex].name : 'Reeks ${spot.barIndex + 1}'}: ${_fmtNum(spot.y)}',
|
||||||
LineTooltipItem(
|
style,
|
||||||
'${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(),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -2880,6 +2886,195 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _radarChart(ChartSpec spec, Color textColor) {
|
||||||
|
if (spec.x.length < 3 || spec.series.isEmpty) {
|
||||||
|
return _placeholderText(
|
||||||
|
context.l10n.d('Een spider-diagram heeft minstens drie labels nodig'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final grid = textColor.withValues(alpha: 0.18);
|
||||||
|
final scale = radarScale(spec);
|
||||||
|
final bg = _hexColor(profile.slideBackgroundColor);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: w * 0.06, vertical: w * 0.012),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// A square keeps fl_chart's centre/radius predictable so the tick
|
||||||
|
// labels we overlay line up exactly with its grid rings.
|
||||||
|
final side = math.min(constraints.maxWidth, constraints.maxHeight);
|
||||||
|
final centerOffset = side / 2;
|
||||||
|
final radius = centerOffset * 0.8; // matches RadarChartPainter
|
||||||
|
final tickStyle = _applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
fontSize: w * 0.012 * _labelScale,
|
||||||
|
color: textColor.withValues(alpha: 0.6),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: side,
|
||||||
|
height: side,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: RadarChart(
|
||||||
|
RadarChartData(
|
||||||
|
dataSets: [
|
||||||
|
for (var si = 0; si < spec.series.length; si++)
|
||||||
|
RadarDataSet(
|
||||||
|
dataEntries: [
|
||||||
|
for (var xi = 0; xi < spec.x.length; xi++)
|
||||||
|
RadarEntry(
|
||||||
|
value: xi < spec.series[si].data.length
|
||||||
|
? spec.series[si].data[xi]
|
||||||
|
: 0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
fillColor: _seriesDisplayColor(
|
||||||
|
spec.series[si],
|
||||||
|
si,
|
||||||
|
).withValues(alpha: _dimmed(si) ? 0.04 : 0.16),
|
||||||
|
borderColor: _seriesDisplayColor(
|
||||||
|
spec.series[si],
|
||||||
|
si,
|
||||||
|
),
|
||||||
|
borderWidth: w * (_hovered == si ? 0.0055 : 0.0035),
|
||||||
|
entryRadius: w * (_hovered == si ? 0.006 : 0.004),
|
||||||
|
),
|
||||||
|
// Invisible anchor pinning the scale to [lo, hi] so the
|
||||||
|
// rings — and our labels — represent a fixed scale.
|
||||||
|
RadarDataSet(
|
||||||
|
dataEntries: [
|
||||||
|
for (var xi = 0; xi < spec.x.length; xi++)
|
||||||
|
RadarEntry(value: xi == 0 ? scale.hi : scale.lo),
|
||||||
|
],
|
||||||
|
fillColor: Colors.transparent,
|
||||||
|
borderColor: Colors.transparent,
|
||||||
|
borderWidth: 0,
|
||||||
|
entryRadius: 0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
radarShape: RadarShape.polygon,
|
||||||
|
radarBackgroundColor: Colors.transparent,
|
||||||
|
radarBorderData: BorderSide(color: grid, width: 1),
|
||||||
|
gridBorderData: BorderSide(color: grid, width: 1),
|
||||||
|
tickBorderData: BorderSide(color: grid, width: 1),
|
||||||
|
tickCount: scale.ticks,
|
||||||
|
isMinValueAtCenter: true,
|
||||||
|
// Hide fl_chart's own ring numbers; we draw labelled
|
||||||
|
// ticks ourselves so any min/max scale reads correctly.
|
||||||
|
ticksTextStyle: const TextStyle(
|
||||||
|
color: Colors.transparent,
|
||||||
|
fontSize: 0.001,
|
||||||
|
),
|
||||||
|
titlePositionPercentageOffset: 0.14,
|
||||||
|
getTitle: (index, angle) => RadarChartTitle(
|
||||||
|
text: index < spec.x.length ? spec.x[index] : '',
|
||||||
|
),
|
||||||
|
titleTextStyle: _applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
fontSize: w * 0.0135 * _labelScale,
|
||||||
|
color: textColor.withValues(alpha: 0.88),
|
||||||
|
fontWeight: presentationMode
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
radarTouchData: RadarTouchData(enabled: false),
|
||||||
|
),
|
||||||
|
duration: Duration.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Evenly spaced scale labels up the top spoke: lo at centre,
|
||||||
|
// hi at the outer ring, with equal steps between.
|
||||||
|
for (var k = 0; k <= scale.ticks; k++)
|
||||||
|
Positioned(
|
||||||
|
left: centerOffset + w * 0.006,
|
||||||
|
top:
|
||||||
|
centerOffset -
|
||||||
|
radius * k / scale.ticks -
|
||||||
|
w * 0.01 * _labelScale,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: w * 0.004,
|
||||||
|
),
|
||||||
|
color: bg.withValues(alpha: 0.7),
|
||||||
|
child: Text(
|
||||||
|
_fmtNum(scale.lo + (scale.hi - scale.lo) * k / scale.ticks),
|
||||||
|
style: tickStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the radar scale: a low/high pair plus an even tick count. Honours
|
||||||
|
/// the optional [ChartSpec.minBound]/[maxBound] and otherwise rounds the data
|
||||||
|
/// range to a tidy scale so the rings read as round numbers.
|
||||||
|
({double lo, double hi, int ticks}) radarScale(ChartSpec spec) {
|
||||||
|
var dataMin = 0.0;
|
||||||
|
var dataMax = 0.0;
|
||||||
|
var seen = false;
|
||||||
|
for (final s in spec.series) {
|
||||||
|
for (final v in s.data) {
|
||||||
|
if (!seen) {
|
||||||
|
dataMin = v;
|
||||||
|
dataMax = v;
|
||||||
|
seen = true;
|
||||||
|
} else {
|
||||||
|
if (v < dataMin) dataMin = v;
|
||||||
|
if (v > dataMax) dataMax = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!seen) {
|
||||||
|
dataMin = 0;
|
||||||
|
dataMax = 1;
|
||||||
|
}
|
||||||
|
final rawLo = spec.minBound ?? (dataMin < 0 ? dataMin : 0);
|
||||||
|
final rawHi = spec.maxBound ?? dataMax;
|
||||||
|
final nice = _niceScale(rawLo, rawHi);
|
||||||
|
final lo = spec.minBound ?? nice.lo;
|
||||||
|
var hi = spec.maxBound ?? nice.hi;
|
||||||
|
if (hi <= lo) hi = lo + nice.step;
|
||||||
|
final ticks = math.max(2, ((hi - lo) / nice.step).round());
|
||||||
|
return (lo: lo, hi: hi, ticks: ticks);
|
||||||
|
}
|
||||||
|
|
||||||
|
({double lo, double hi, double step}) _niceScale(double lo, double hi) {
|
||||||
|
final range = (hi - lo).abs();
|
||||||
|
final r = range <= 0 ? 1.0 : range;
|
||||||
|
final rawStep = r / 4;
|
||||||
|
final mag = math.pow(10, (math.log(rawStep) / math.ln10).floor()).toDouble();
|
||||||
|
final norm = rawStep / mag;
|
||||||
|
final niceNorm = norm < 1.5
|
||||||
|
? 1.0
|
||||||
|
: norm < 3
|
||||||
|
? 2.0
|
||||||
|
: norm < 7
|
||||||
|
? 5.0
|
||||||
|
: 10.0;
|
||||||
|
final step = niceNorm * mag;
|
||||||
|
return (
|
||||||
|
lo: (lo / step).floor() * step,
|
||||||
|
hi: (hi / step).ceil() * step,
|
||||||
|
step: step,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
TextStyle _tooltipStyle() => _applyFont(
|
TextStyle _tooltipStyle() => _applyFont(
|
||||||
font,
|
font,
|
||||||
TextStyle(
|
TextStyle(
|
||||||
|
|
@ -2890,6 +3085,22 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Tooltip style for line charts. Each touched dot adds two lines, so when
|
||||||
|
/// several dots overlap the font shrinks a step to keep the stack readable.
|
||||||
|
TextStyle _lineTooltipStyle(int count) {
|
||||||
|
final base = (w * 0.013 * _labelScale).clamp(11.0, 18.0);
|
||||||
|
final shrink = (1 - (count - 2) * 0.13).clamp(0.6, 1.0);
|
||||||
|
return _applyFont(
|
||||||
|
font,
|
||||||
|
TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: (base * shrink).clamp(8.0, 18.0),
|
||||||
|
height: 1.2,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _placeholder(BuildContext context) =>
|
Widget _placeholder(BuildContext context) =>
|
||||||
_placeholderText(context.l10n.d('Geen grafiekgegevens'));
|
_placeholderText(context.l10n.d('Geen grafiekgegevens'));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ void main() {
|
||||||
'SLIDES',
|
'SLIDES',
|
||||||
'Slide',
|
'Slide',
|
||||||
'slide',
|
'slide',
|
||||||
|
'Spider',
|
||||||
};
|
};
|
||||||
final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")''');
|
final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")''');
|
||||||
final files = Directory('lib')
|
final files = Directory('lib')
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,49 @@ void main() {
|
||||||
expect(lineItems.single?.text, 'Februari\nBezoekers: 17.5');
|
expect(lineItems.single?.text, 'Februari\nBezoekers: 17.5');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('line tooltip uses true distance and shows every nearby dot', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
const spec = ChartSpec(
|
||||||
|
type: ChartType.line,
|
||||||
|
x: ['Q1'],
|
||||||
|
series: [
|
||||||
|
ChartSeries(name: 'Alpha', data: [10], color: '#2563EB'),
|
||||||
|
ChartSeries(name: 'Beta', data: [10], color: '#EF4444'),
|
||||||
|
ChartSeries(name: 'Gamma', data: [10], color: '#10B981'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(_host(spec));
|
||||||
|
final line = tester.widget<LineChart>(find.byType(LineChart));
|
||||||
|
final touch = line.data.lineTouchData;
|
||||||
|
|
||||||
|
// Proximity is Euclidean (x AND y), not the x-only default.
|
||||||
|
expect(touch.distanceCalculator(Offset.zero, const Offset(3, 4)), 5);
|
||||||
|
expect(touch.touchSpotThreshold, greaterThan(0));
|
||||||
|
|
||||||
|
final spots = [
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
LineBarSpot(
|
||||||
|
line.data.lineBarsData[i],
|
||||||
|
i,
|
||||||
|
line.data.lineBarsData[i].spots.single,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
final items = touch.touchTooltipData.getTooltipItems(spots);
|
||||||
|
// All overlapping dots are shown (none filtered out).
|
||||||
|
expect(items.length, 3);
|
||||||
|
expect(items.whereType<LineTooltipItem>().length, 3);
|
||||||
|
expect(items[0]?.text, 'Q1\nAlpha: 10');
|
||||||
|
expect(items[2]?.text, 'Q1\nGamma: 10');
|
||||||
|
|
||||||
|
// A crowded stack uses a smaller font than a single tooltip.
|
||||||
|
final single = touch.touchTooltipData.getTooltipItems([spots.first]);
|
||||||
|
expect(
|
||||||
|
items[0]!.textStyle!.fontSize!,
|
||||||
|
lessThan(single.single!.textStyle!.fontSize!),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('pie hover shows the underlying category value', (tester) async {
|
testWidgets('pie hover shows the underlying category value', (tester) async {
|
||||||
const spec = ChartSpec(
|
const spec = ChartSpec(
|
||||||
type: ChartType.pie,
|
type: ChartType.pie,
|
||||||
|
|
@ -230,6 +273,83 @@ void main() {
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('radar chart renders a polygon per series with axis labels', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
const spec = ChartSpec(
|
||||||
|
type: ChartType.radar,
|
||||||
|
x: ['Snelheid', 'Kracht', 'Uithouding'],
|
||||||
|
series: [
|
||||||
|
ChartSeries(name: 'Alpha', data: [3, 4, 5], color: '#2563EB'),
|
||||||
|
ChartSeries(name: 'Beta', data: [5, 2, 3], color: '#EF4444'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_host(spec));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final radar = tester.widget<RadarChart>(find.byType(RadarChart));
|
||||||
|
// Two visible series plus one invisible scale anchor.
|
||||||
|
expect(radar.data.dataSets.length, 3);
|
||||||
|
expect(radar.data.dataSets.first.dataEntries.map((e) => e.value), [3, 4, 5]);
|
||||||
|
expect(radar.data.dataSets.last.fillColor, Colors.transparent);
|
||||||
|
// The spoke labels are supplied through getTitle (canvas-painted).
|
||||||
|
expect(radar.data.getTitle!(0, 0).text, 'Snelheid');
|
||||||
|
expect(radar.data.getTitle!(2, 0).text, 'Uithouding');
|
||||||
|
// The series legend is shown as real text widgets.
|
||||||
|
expect(find.text('Alpha'), findsOneWidget);
|
||||||
|
expect(find.text('Beta'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('radar honours an explicit min/max scale with even ticks', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
const spec = ChartSpec(
|
||||||
|
type: ChartType.radar,
|
||||||
|
x: ['A', 'B', 'C', 'D'],
|
||||||
|
series: [
|
||||||
|
ChartSeries(name: 'Score', data: [2, 4, 3, 5]),
|
||||||
|
],
|
||||||
|
minBound: 0,
|
||||||
|
maxBound: 10,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_host(spec));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final radar = tester.widget<RadarChart>(find.byType(RadarChart));
|
||||||
|
expect(radar.data.isMinValueAtCenter, isTrue);
|
||||||
|
// The hidden anchor pins the scale to [0, 10].
|
||||||
|
final anchor = radar.data.dataSets.last.dataEntries.map((e) => e.value);
|
||||||
|
expect(anchor.reduce((a, b) => a < b ? a : b), 0);
|
||||||
|
expect(anchor.reduce((a, b) => a > b ? a : b), 10);
|
||||||
|
// Evenly spaced scale labels are drawn (0..10).
|
||||||
|
expect(find.text('0'), findsWidgets);
|
||||||
|
expect(find.text('10'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('radar chart asks for at least three labels', (tester) async {
|
||||||
|
const spec = ChartSpec(
|
||||||
|
type: ChartType.radar,
|
||||||
|
x: ['Een', 'Twee'],
|
||||||
|
series: [
|
||||||
|
ChartSeries(name: 'Alpha', data: [3, 4]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_host(spec));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(RadarChart), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.text('Een spider-diagram heeft minstens drie labels nodig'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('presentation mode enlarges chart labels', (tester) async {
|
testWidgets('presentation mode enlarges chart labels', (tester) async {
|
||||||
const spec = ChartSpec(
|
const spec = ChartSpec(
|
||||||
type: ChartType.bar,
|
type: ChartType.bar,
|
||||||
|
|
|
||||||
|
|
@ -130,5 +130,44 @@ void main() {
|
||||||
expect(back.minBound, isNull);
|
expect(back.minBound, isNull);
|
||||||
expect(back.maxBound, isNull);
|
expect(back.maxBound, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('round-trips a spider/radar chart type', () {
|
||||||
|
const spec = ChartSpec(
|
||||||
|
type: ChartType.radar,
|
||||||
|
x: ['Snelheid', 'Kracht', 'Uithouding'],
|
||||||
|
series: [
|
||||||
|
ChartSeries(name: 'A', data: [3, 4, 5]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final back = ChartSpec.parse(spec.toBlock());
|
||||||
|
expect(back.type, ChartType.radar);
|
||||||
|
expect(back.x, ['Snelheid', 'Kracht', 'Uithouding']);
|
||||||
|
expect(back.series.single.data, [3, 4, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('radar keeps bounds as a scale but never draws bound lines', () {
|
||||||
|
const spec = ChartSpec(
|
||||||
|
type: ChartType.radar,
|
||||||
|
x: ['A', 'B', 'C'],
|
||||||
|
series: [ChartSeries(name: 'A', data: [1, 2, 3])],
|
||||||
|
minBound: 1,
|
||||||
|
maxBound: 5,
|
||||||
|
);
|
||||||
|
expect(spec.supportsBounds, isTrue);
|
||||||
|
expect(spec.supportsBoundLines, isFalse);
|
||||||
|
final back = ChartSpec.parse(spec.toBlock());
|
||||||
|
expect(back.minBound, 1);
|
||||||
|
expect(back.maxBound, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bar/line draw bound lines but pie does not', () {
|
||||||
|
const bar = ChartSpec(type: ChartType.bar);
|
||||||
|
const line = ChartSpec(type: ChartType.line);
|
||||||
|
const pie = ChartSpec(type: ChartType.pie);
|
||||||
|
expect(bar.supportsBoundLines, isTrue);
|
||||||
|
expect(line.supportsBoundLines, isTrue);
|
||||||
|
expect(pie.supportsBoundLines, isFalse);
|
||||||
|
expect(pie.supportsBounds, isFalse);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
83
test/code_preview_test.dart
Normal file
83
test/code_preview_test.dart
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_highlight/flutter_highlight.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';
|
||||||
|
|
||||||
|
Widget _host(Slide slide, ThemeProfile profile) {
|
||||||
|
return Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 800,
|
||||||
|
height: 450,
|
||||||
|
child: SlidePreviewWidget(slide: slide, themeProfile: profile),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _hex(String hex) =>
|
||||||
|
Color(int.parse(hex.substring(1), radix: 16) | 0xFF000000);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('code slide paints the themed background colour', (tester) async {
|
||||||
|
final slide = Slide.create(
|
||||||
|
SlideType.code,
|
||||||
|
).copyWith(codeLanguage: 'dart', customMarkdown: 'void main() {}');
|
||||||
|
const profile = ThemeProfile(
|
||||||
|
codeBackgroundColor: '#000000',
|
||||||
|
codeTextColor: '#33FF33',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_host(slide, profile));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// The code panel uses the themed background somewhere in its decoration.
|
||||||
|
final painted = tester.widgetList<Container>(find.byType(Container)).where((
|
||||||
|
c,
|
||||||
|
) {
|
||||||
|
final d = c.decoration;
|
||||||
|
return d is BoxDecoration && d.color == _hex('#000000');
|
||||||
|
});
|
||||||
|
expect(painted, isNotEmpty);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('syntax highlighting on uses HighlightView for a known language', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final slide = Slide.create(
|
||||||
|
SlideType.code,
|
||||||
|
).copyWith(codeLanguage: 'dart', customMarkdown: 'void main() {}');
|
||||||
|
const profile = ThemeProfile(codeHighlightSyntax: true);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_host(slide, profile));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(HighlightView), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('syntax highlighting off renders monochrome (CRT) text', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final slide = Slide.create(
|
||||||
|
SlideType.code,
|
||||||
|
).copyWith(codeLanguage: 'dart', customMarkdown: 'void main() {}');
|
||||||
|
const profile = ThemeProfile(
|
||||||
|
codeBackgroundColor: '#000000',
|
||||||
|
codeTextColor: '#33FF33',
|
||||||
|
codeHighlightSyntax: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_host(slide, profile));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// No per-token highlighting; the code is one flat colour.
|
||||||
|
expect(find.byType(HighlightView), findsNothing);
|
||||||
|
final codeText = tester.widget<Text>(find.text('void main() {}'));
|
||||||
|
expect(codeText.style?.color, _hex('#33FF33'));
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -98,6 +98,21 @@ void main() {}
|
||||||
expect(html, isNot(contains('data:font/ttf;base64,')));
|
expect(html, isNot(contains('data:font/ttf;base64,')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('code blocks use the themed code colours in the export CSS', () async {
|
||||||
|
final service = MarpHtmlService(
|
||||||
|
loadAsset: _diskLoader,
|
||||||
|
loadBytes: _diskBytes,
|
||||||
|
);
|
||||||
|
const theme = ThemeProfile(
|
||||||
|
codeBackgroundColor: '#000000',
|
||||||
|
codeTextColor: '#33FF33',
|
||||||
|
);
|
||||||
|
final html = await service.build('```dart\nvoid main() {}\n```', theme: theme);
|
||||||
|
|
||||||
|
expect(html, contains('.slide pre{background:#000000;color:#33FF33'));
|
||||||
|
expect(html, contains('.slide pre code{color:#33FF33'));
|
||||||
|
});
|
||||||
|
|
||||||
test('EB Garamond theme embeds the font for offline rendering', () async {
|
test('EB Garamond theme embeds the font for offline rendering', () async {
|
||||||
final service = MarpHtmlService(
|
final service = MarpHtmlService(
|
||||||
loadAsset: _diskLoader,
|
loadAsset: _diskLoader,
|
||||||
|
|
@ -195,4 +210,32 @@ void main() {}
|
||||||
expect(html, isNot(contains('stroke-dasharray')));
|
expect(html, isNot(contains('stroke-dasharray')));
|
||||||
expect(html, isNot(contains('min 5')));
|
expect(html, isNot(contains('min 5')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('radar chart SVG draws a polygon per series with axis labels', () {
|
||||||
|
const slide = '''
|
||||||
|
```chart
|
||||||
|
{
|
||||||
|
"type": "radar",
|
||||||
|
"x": ["Snelheid", "Kracht", "Uithouding"],
|
||||||
|
"series": [
|
||||||
|
{"name": "A", "color": "#2563EB", "data": [3, 4, 5]},
|
||||||
|
{"name": "B", "color": "#EF4444", "data": [5, 2, 3]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
''';
|
||||||
|
|
||||||
|
final html = MarpHtmlService.renderChartBlocks(slide);
|
||||||
|
|
||||||
|
expect(html, contains('<polygon'));
|
||||||
|
expect(html, contains('Snelheid'));
|
||||||
|
expect(html, contains('Kracht'));
|
||||||
|
expect(html, contains('Uithouding'));
|
||||||
|
// Both series are drawn with their colours.
|
||||||
|
expect(html, contains('fill="#2563EB"'));
|
||||||
|
expect(html, contains('fill="#EF4444"'));
|
||||||
|
// The series legend is shown (not a pie legend).
|
||||||
|
expect(html, contains('A'));
|
||||||
|
expect(html, contains('B'));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,26 @@ Future<SettingsNotifier> _loadedNotifier() async {
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
test('ThemeProfile round-trips the code colours through JSON', () {
|
||||||
|
const profile = ThemeProfile(
|
||||||
|
codeBackgroundColor: '#000000',
|
||||||
|
codeTextColor: '#33FF33',
|
||||||
|
codeHighlightSyntax: false,
|
||||||
|
);
|
||||||
|
final back = ThemeProfile.fromJson(profile.toJson());
|
||||||
|
expect(back.codeBackgroundColor, '#000000');
|
||||||
|
expect(back.codeTextColor, '#33FF33');
|
||||||
|
expect(back.codeHighlightSyntax, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ThemeProfile code colours default to the atom-one-dark look', () {
|
||||||
|
// Older decks without the fields fall back to the dark editor defaults.
|
||||||
|
final back = ThemeProfile.fromJson(const {'name': 'Legacy'});
|
||||||
|
expect(back.codeBackgroundColor, '#282C34');
|
||||||
|
expect(back.codeTextColor, '#ABB2BF');
|
||||||
|
expect(back.codeHighlightSyntax, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
test('starts with a single default profile', () async {
|
test('starts with a single default profile', () async {
|
||||||
final notifier = await _loadedNotifier();
|
final notifier = await _loadedNotifier();
|
||||||
expect(notifier.state.themeProfiles, hasLength(1));
|
expect(notifier.state.themeProfiles, hasLength(1));
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue