Add code-slide theming, radar scale, and proximity line tooltips
Code slides: - Theme code (broncode) background and text colours, with an optional syntax-colouring toggle. With it off the block renders monochrome, so a black background + bright green gives a classic CRT-screen look. - Colour pickers gained a custom hex entry so arbitrary colours (e.g. CRT green) can be set, not just presets. Exported HTML mirrors the code colours. Radar/spider charts: - Optional min/max now define the radar scale (centre/outer ring) instead of threshold lines. Evenly spaced, labelled tick rings are drawn in both the live preview and the SVG export so the scale is readable. A nice scale is derived from the data when no bounds are set. Line chart tooltips: - Detect the touched dot by true (x and y) distance instead of the x-only default, so the tooltip belongs to the point under the cursor. Overlapping dots all show, and the font shrinks a step when several stack. New UI strings are translated across all supported languages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
67408c213c
commit
de4a77e2bb
13 changed files with 1024 additions and 41 deletions
|
|
@ -2342,6 +2342,7 @@ const _dutchSourceStringAdditions = {
|
|||
'Staaf': 'Bar',
|
||||
'Lijn': 'Line',
|
||||
'Cirkel': 'Pie',
|
||||
'Spider': 'Spider',
|
||||
'CSV importeren': 'Import CSV',
|
||||
'Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)':
|
||||
'Data (CSV: first row = series names, first column = labels)',
|
||||
|
|
@ -2364,6 +2365,23 @@ const _dutchSourceStringAdditions = {
|
|||
'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.',
|
||||
'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',
|
||||
'Titel (optioneel)': 'Title (optional)',
|
||||
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.':
|
||||
|
|
@ -2382,6 +2400,24 @@ const _dutchSourceStringAdditions = {
|
|||
},
|
||||
'it': {
|
||||
'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 rij': 'Colore della riga',
|
||||
'Hexkleur': 'Colore esadecimale',
|
||||
|
|
@ -2590,6 +2626,24 @@ const _dutchSourceStringAdditions = {
|
|||
},
|
||||
'de': {
|
||||
'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 rij': 'Zeilenfarbe',
|
||||
'Hexkleur': 'Hex-Farbe',
|
||||
|
|
@ -2799,6 +2853,24 @@ const _dutchSourceStringAdditions = {
|
|||
},
|
||||
'fr': {
|
||||
'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 rij': 'Couleur de la ligne',
|
||||
'Hexkleur': 'Couleur hexadécimale',
|
||||
|
|
@ -3008,6 +3080,24 @@ const _dutchSourceStringAdditions = {
|
|||
},
|
||||
'es': {
|
||||
'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 rij': 'Color de la fila',
|
||||
'Hexkleur': 'Color hexadecimal',
|
||||
|
|
@ -3217,6 +3307,24 @@ const _dutchSourceStringAdditions = {
|
|||
},
|
||||
'fy': {
|
||||
'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 rij': 'Rijekleur',
|
||||
'Hexkleur': 'Hekskleur',
|
||||
|
|
@ -3423,6 +3531,24 @@ const _dutchSourceStringAdditions = {
|
|||
},
|
||||
'pap': {
|
||||
'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 rij': 'Koló di liña',
|
||||
'Hexkleur': 'Koló hexadecimal',
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const List<String> chartColorPalette = [
|
|||
];
|
||||
|
||||
/// 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(
|
||||
(t) => t.name == name,
|
||||
|
|
@ -100,9 +100,15 @@ class ChartSpec {
|
|||
|
||||
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;
|
||||
|
||||
/// True only where bounds render as horizontal threshold *lines*.
|
||||
bool get supportsBoundLines =>
|
||||
type == ChartType.bar || type == ChartType.line;
|
||||
|
||||
ChartSpec copyWith({
|
||||
ChartType? type,
|
||||
String? title,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,17 @@ class ThemeProfile {
|
|||
final String titleBackgroundColor;
|
||||
final String titleTextColor;
|
||||
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 logoPosition;
|
||||
final int logoSize;
|
||||
|
|
@ -40,6 +51,9 @@ class ThemeProfile {
|
|||
this.titleBackgroundColor = '#1C2B47',
|
||||
this.titleTextColor = '#FFFFFF',
|
||||
this.sectionBackgroundColor = '#2E7D64',
|
||||
this.codeBackgroundColor = '#282C34',
|
||||
this.codeTextColor = '#ABB2BF',
|
||||
this.codeHighlightSyntax = true,
|
||||
this.logoPath,
|
||||
this.logoPosition = 'bottom-right',
|
||||
this.logoSize = 96,
|
||||
|
|
@ -70,6 +84,9 @@ class ThemeProfile {
|
|||
String? titleBackgroundColor,
|
||||
String? titleTextColor,
|
||||
String? sectionBackgroundColor,
|
||||
String? codeBackgroundColor,
|
||||
String? codeTextColor,
|
||||
bool? codeHighlightSyntax,
|
||||
String? logoPath,
|
||||
String? logoPosition,
|
||||
int? logoSize,
|
||||
|
|
@ -92,6 +109,9 @@ class ThemeProfile {
|
|||
titleTextColor: titleTextColor ?? this.titleTextColor,
|
||||
sectionBackgroundColor:
|
||||
sectionBackgroundColor ?? this.sectionBackgroundColor,
|
||||
codeBackgroundColor: codeBackgroundColor ?? this.codeBackgroundColor,
|
||||
codeTextColor: codeTextColor ?? this.codeTextColor,
|
||||
codeHighlightSyntax: codeHighlightSyntax ?? this.codeHighlightSyntax,
|
||||
logoPath: clearLogo ? null : (logoPath ?? this.logoPath),
|
||||
logoPosition: logoPosition ?? this.logoPosition,
|
||||
logoSize: logoSize ?? this.logoSize,
|
||||
|
|
@ -116,6 +136,9 @@ class ThemeProfile {
|
|||
'titleBackgroundColor': titleBackgroundColor,
|
||||
'titleTextColor': titleTextColor,
|
||||
'sectionBackgroundColor': sectionBackgroundColor,
|
||||
'codeBackgroundColor': codeBackgroundColor,
|
||||
'codeTextColor': codeTextColor,
|
||||
'codeHighlightSyntax': codeHighlightSyntax,
|
||||
'logoPath': logoPath,
|
||||
'logoPosition': logoPosition,
|
||||
'logoSize': logoSize,
|
||||
|
|
@ -146,6 +169,10 @@ class ThemeProfile {
|
|||
titleTextColor: json['titleTextColor'] as String? ?? '#FFFFFF',
|
||||
sectionBackgroundColor:
|
||||
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?,
|
||||
logoPosition: json['logoPosition'] as String? ?? 'bottom-right',
|
||||
logoSize: (json['logoSize'] as num?)?.round() ?? 96,
|
||||
|
|
|
|||
|
|
@ -171,6 +171,8 @@ class MarpHtmlService {
|
|||
case ChartType.pie:
|
||||
final legendRows = (spec.x.length / 6).ceil().clamp(1, 3);
|
||||
_pieSvg(b, spec, plotTop, theme, bottom: 398 - (legendRows - 1) * 28);
|
||||
case ChartType.radar:
|
||||
_radarSvg(b, spec, plotTop, theme, textColor);
|
||||
}
|
||||
if (spec.type == ChartType.pie) {
|
||||
_pieLegendSvg(b, spec, textColor);
|
||||
|
|
@ -269,7 +271,7 @@ class MarpHtmlService {
|
|||
double bottom,
|
||||
double maxY,
|
||||
) {
|
||||
if (!spec.supportsBounds) return;
|
||||
if (!spec.supportsBoundLines) return;
|
||||
void draw(double? value, String color, String prefix) {
|
||||
if (value == null || value < 0 || value > maxY) return;
|
||||
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) =>
|
||||
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 a{color:${t.accentColor}}'
|
||||
'.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}'
|
||||
'.slide pre code{color:${t.codeTextColor};background:transparent}'
|
||||
'.slide code{font-family:SFMono-Regular,Consolas,"Liberation Mono",monospace}'
|
||||
'.slide pre.mermaid{background:transparent;border:0;text-align:center}'
|
||||
'.slide img{max-width:100%}'
|
||||
|
|
|
|||
|
|
@ -939,6 +939,41 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
(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),
|
||||
_stylePreview(),
|
||||
],
|
||||
|
|
@ -1149,18 +1184,143 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
for (final color in _colorPresets)
|
||||
_colorSwatch(
|
||||
color,
|
||||
selected: value == color,
|
||||
selected: value.toUpperCase() == color,
|
||||
onTap: () => setState(() {
|
||||
onChanged(color);
|
||||
_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(
|
||||
String color, {
|
||||
required bool selected,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
_loadFromSpec(spec);
|
||||
}
|
||||
|
||||
bool get _supportsBounds => _type != ChartType.pie;
|
||||
|
||||
static String _fmtBound(double? v) => v == null ? '' : _fmt(v);
|
||||
|
||||
static double? _parseBound(String raw) {
|
||||
|
|
@ -133,8 +135,8 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
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),
|
||||
minBound: _supportsBounds ? _parseBound(_minBound.text) : null,
|
||||
maxBound: _supportsBounds ? _parseBound(_maxBound.text) : null,
|
||||
);
|
||||
widget.onUpdate(widget.slide.copyWith(customMarkdown: spec.toBlock()));
|
||||
}
|
||||
|
|
@ -467,6 +469,10 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
value: ChartType.pie,
|
||||
child: Text(l10n.d('Cirkel')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ChartType.radar,
|
||||
child: Text(l10n.d('Spider')),
|
||||
),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
|
|
@ -492,7 +498,17 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
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: const EdgeInsets.only(top: 12),
|
||||
child: Row(
|
||||
|
|
@ -502,7 +518,9 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
child: _boundField(
|
||||
key: const ValueKey('chart-min-bound'),
|
||||
controller: _minBound,
|
||||
label: l10n.d('Minimumlijn (optioneel)'),
|
||||
label: _type == ChartType.radar
|
||||
? l10n.d('Schaalminimum (optioneel)')
|
||||
: l10n.d('Minimumlijn (optioneel)'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
|
@ -510,7 +528,9 @@ class _ChartEditorState extends State<ChartEditor> {
|
|||
child: _boundField(
|
||||
key: const ValueKey('chart-max-bound'),
|
||||
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 known = lang.isNotEmpty && allLanguages.containsKey(lang);
|
||||
|
||||
final codeBg = _hexColor(profile.codeBackgroundColor);
|
||||
final codeFg = _hexColor(profile.codeTextColor);
|
||||
|
||||
final mono = TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
|
||||
fontSize: w * 0.024,
|
||||
height: 1.4,
|
||||
color: const Color(0xFFABB2BF), // atom-one-dark voorgrond
|
||||
color: codeFg,
|
||||
);
|
||||
|
||||
// HighlightView gooit een fout bij een onbekende taal; daarom vallen we
|
||||
// dan terug op platte (maar wel monospace) tekst.
|
||||
final Widget codeContent = known
|
||||
// HighlightView throws on an unknown language, so fall back to plain (but
|
||||
// monospace) text. When syntax highlighting is off we always render plain
|
||||
// text so the whole block is one colour — needed for a CRT-green screen.
|
||||
final Widget codeContent = (known && profile.codeHighlightSyntax)
|
||||
? HighlightView(
|
||||
code,
|
||||
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,
|
||||
textStyle: mono,
|
||||
)
|
||||
|
|
@ -2127,9 +2139,9 @@ class _CodePreview extends StatelessWidget {
|
|||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF282C34), // atom-one-dark achtergrond
|
||||
color: codeBg,
|
||||
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),
|
||||
child: Column(
|
||||
|
|
@ -2144,7 +2156,7 @@ class _CodePreview extends StatelessWidget {
|
|||
TextStyle(
|
||||
fontSize: w * 0.03,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: const Color(0xFFE5E7EB),
|
||||
color: codeFg,
|
||||
),
|
||||
),
|
||||
linkColor: _hexColor(profile.accentColor),
|
||||
|
|
@ -2475,6 +2487,8 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
|||
return _lineChart(spec, textColor);
|
||||
case ChartType.pie:
|
||||
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).
|
||||
ExtraLinesData _boundLines(ChartSpec spec) {
|
||||
if (!spec.supportsBounds) return const ExtraLinesData();
|
||||
if (!spec.supportsBoundLines) return const ExtraLinesData();
|
||||
final dash = [
|
||||
(w * 0.018).round().clamp(4, 14),
|
||||
(w * 0.01).round().clamp(3, 9),
|
||||
|
|
@ -2729,6 +2743,10 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
|||
extraLinesData: _boundLines(spec),
|
||||
lineTouchData: LineTouchData(
|
||||
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) =>
|
||||
response?.lineBarSpots?.isEmpty ?? true
|
||||
? SystemMouseCursors.basic
|
||||
|
|
@ -2737,30 +2755,18 @@ class _ChartPreviewState extends State<_ChartPreview> {
|
|||
fitInsideHorizontally: true,
|
||||
fitInsideVertically: true,
|
||||
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) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
final style = _lineTooltipStyle(spots.length);
|
||||
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(),
|
||||
),
|
||||
for (final spot in spots)
|
||||
LineTooltipItem(
|
||||
'${spot.spotIndex < spec.x.length ? spec.x[spot.spotIndex] : ''}\n'
|
||||
'${spot.barIndex < spec.series.length && spec.series[spot.barIndex].name.isNotEmpty ? spec.series[spot.barIndex].name : 'Reeks ${spot.barIndex + 1}'}: ${_fmtNum(spot.y)}',
|
||||
style,
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
|
|
@ -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(
|
||||
font,
|
||||
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) =>
|
||||
_placeholderText(context.l10n.d('Geen grafiekgegevens'));
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ void main() {
|
|||
'SLIDES',
|
||||
'Slide',
|
||||
'slide',
|
||||
'Spider',
|
||||
};
|
||||
final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")''');
|
||||
final files = Directory('lib')
|
||||
|
|
|
|||
|
|
@ -151,6 +151,49 @@ void main() {
|
|||
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 {
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.pie,
|
||||
|
|
@ -230,6 +273,83 @@ void main() {
|
|||
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 {
|
||||
const spec = ChartSpec(
|
||||
type: ChartType.bar,
|
||||
|
|
|
|||
|
|
@ -130,5 +130,44 @@ void main() {
|
|||
expect(back.minBound, 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,')));
|
||||
});
|
||||
|
||||
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 {
|
||||
final service = MarpHtmlService(
|
||||
loadAsset: _diskLoader,
|
||||
|
|
@ -195,4 +210,32 @@ void main() {}
|
|||
expect(html, isNot(contains('stroke-dasharray')));
|
||||
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() {
|
||||
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 {
|
||||
final notifier = await _loadedNotifier();
|
||||
expect(notifier.state.themeProfiles, hasLength(1));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue