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>
255 lines
8.3 KiB
Dart
255 lines
8.3 KiB
Dart
import 'dart:convert';
|
|
|
|
/// Directory (relative to the deck) where linked chart CSVs are kept, so the
|
|
/// data files stay tidily in one place — separate from images/media.
|
|
const String chartDataDirName = 'data';
|
|
|
|
const List<String> chartColorPalette = [
|
|
'#003399',
|
|
'#FFCC00',
|
|
'#2563EB',
|
|
'#F59E0B',
|
|
'#10B981',
|
|
'#EF4444',
|
|
'#8B5CF6',
|
|
'#06B6D4',
|
|
'#EC4899',
|
|
'#84CC16',
|
|
];
|
|
|
|
/// Supported chart kinds for a chart slide.
|
|
enum ChartType { bar, line, pie, radar }
|
|
|
|
ChartType _chartTypeFromName(String? name) => ChartType.values.firstWhere(
|
|
(t) => t.name == name,
|
|
orElse: () => ChartType.bar,
|
|
);
|
|
|
|
/// One named data series (a row of values aligned to the x labels).
|
|
class ChartSeries {
|
|
final String name;
|
|
final List<double> data;
|
|
final String? color;
|
|
const ChartSeries({required this.name, required this.data, this.color});
|
|
|
|
Map<String, dynamic> toJson({bool includeData = true}) => {
|
|
'name': name,
|
|
if (includeData) 'data': data,
|
|
if (color != null) 'color': color,
|
|
};
|
|
|
|
factory ChartSeries.fromJson(Map<String, dynamic> json) {
|
|
final color = normalizeChartColor(json['color']?.toString());
|
|
return ChartSeries(
|
|
name: (json['name'] ?? '').toString(),
|
|
color: color,
|
|
data: [
|
|
for (final v in (json['data'] as List? ?? const []))
|
|
(v as num?)?.toDouble() ?? 0,
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
String? normalizeChartColor(String? value) {
|
|
if (value == null) return null;
|
|
final raw = value.trim().toUpperCase();
|
|
final normalized = raw.startsWith('#') ? raw : '#$raw';
|
|
return RegExp(r'^#[0-9A-F]{6}$').hasMatch(normalized) ? normalized : null;
|
|
}
|
|
|
|
String chartSeriesColor(ChartSeries series, int index) =>
|
|
normalizeChartColor(series.color) ??
|
|
chartColorPalette[index % chartColorPalette.length];
|
|
|
|
String chartRowColor(ChartSpec spec, int index) => index < spec.rowColors.length
|
|
? normalizeChartColor(spec.rowColors[index]) ??
|
|
chartColorPalette[index % chartColorPalette.length]
|
|
: chartColorPalette[index % chartColorPalette.length];
|
|
|
|
/// The full chart specification, stored as JSON inside a ```chart fenced block.
|
|
///
|
|
/// Small charts keep their data inline; data-driven charts instead point at an
|
|
/// external CSV via [source] (kept as the living source of truth and packaged
|
|
/// alongside the deck like images). When a [source] is set the inline data is
|
|
/// stripped from the markdown on save and re-hydrated from the CSV on load.
|
|
class ChartSpec {
|
|
final ChartType type;
|
|
final String title;
|
|
final String? source;
|
|
final List<String> x;
|
|
final List<String?> rowColors;
|
|
final List<ChartSeries> series;
|
|
|
|
/// Optional horizontal reference lines drawn across the plot so it is clear
|
|
/// where a data point sits relative to a threshold. Only meaningful for bar
|
|
/// and line charts (ignored for pie); either may be left null.
|
|
final double? minBound;
|
|
final double? maxBound;
|
|
|
|
const ChartSpec({
|
|
this.type = ChartType.bar,
|
|
this.title = '',
|
|
this.source,
|
|
this.x = const [],
|
|
this.rowColors = const [],
|
|
this.series = const [],
|
|
this.minBound,
|
|
this.maxBound,
|
|
});
|
|
|
|
bool get hasInlineData => x.isNotEmpty && series.isNotEmpty;
|
|
|
|
/// 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,
|
|
String? source,
|
|
bool clearSource = false,
|
|
List<String>? x,
|
|
List<String?>? rowColors,
|
|
List<ChartSeries>? series,
|
|
double? minBound,
|
|
bool clearMinBound = false,
|
|
double? maxBound,
|
|
bool clearMaxBound = false,
|
|
}) => ChartSpec(
|
|
type: type ?? this.type,
|
|
title: title ?? this.title,
|
|
source: clearSource ? null : (source ?? this.source),
|
|
x: x ?? this.x,
|
|
rowColors: rowColors ?? this.rowColors,
|
|
series: series ?? this.series,
|
|
minBound: clearMinBound ? null : (minBound ?? this.minBound),
|
|
maxBound: clearMaxBound ? null : (maxBound ?? this.maxBound),
|
|
);
|
|
|
|
/// Parse the JSON content of a ```chart block. Tolerant: returns a default
|
|
/// spec on any error so a malformed block never crashes rendering.
|
|
factory ChartSpec.parse(String raw) {
|
|
try {
|
|
final data = jsonDecode(raw.trim());
|
|
if (data is! Map) return const ChartSpec();
|
|
final src = (data['source'] as String?)?.trim();
|
|
return ChartSpec(
|
|
type: _chartTypeFromName(data['type'] as String?),
|
|
title: (data['title'] ?? '').toString(),
|
|
source: (src == null || src.isEmpty) ? null : src,
|
|
minBound: (data['minBound'] as num?)?.toDouble(),
|
|
maxBound: (data['maxBound'] as num?)?.toDouble(),
|
|
x: [for (final v in (data['x'] as List? ?? const [])) v.toString()],
|
|
rowColors: [
|
|
for (final value in (data['rowColors'] as List? ?? const []))
|
|
normalizeChartColor(value?.toString()),
|
|
],
|
|
series: [
|
|
for (final s in (data['series'] as List? ?? const []))
|
|
ChartSeries.fromJson(Map<String, dynamic>.from(s as Map)),
|
|
],
|
|
);
|
|
} catch (_) {
|
|
return const ChartSpec();
|
|
}
|
|
}
|
|
|
|
/// Serialize back to the pretty JSON that lives in the markdown block.
|
|
/// When [forStorage] is true and a [source] is set, the (re-hydratable)
|
|
/// inline data is omitted so the .md stays lean and the CSV stays the source.
|
|
String toBlock({bool forStorage = false}) {
|
|
final map = <String, dynamic>{'type': type.name};
|
|
if (title.isNotEmpty) map['title'] = title;
|
|
if (source != null) map['source'] = source;
|
|
if (supportsBounds) {
|
|
if (minBound != null) map['minBound'] = minBound;
|
|
if (maxBound != null) map['maxBound'] = maxBound;
|
|
}
|
|
final dropData = forStorage && source != null;
|
|
if (rowColors.any((color) => color != null)) {
|
|
map['rowColors'] = rowColors;
|
|
}
|
|
if (!dropData) {
|
|
if (x.isNotEmpty) map['x'] = x;
|
|
if (series.isNotEmpty) {
|
|
map['series'] = [for (final s in series) s.toJson()];
|
|
}
|
|
} else if (series.any((series) => series.color != null)) {
|
|
map['series'] = [
|
|
for (final series in series) series.toJson(includeData: false),
|
|
];
|
|
}
|
|
return const JsonEncoder.withIndent(' ').convert(map);
|
|
}
|
|
|
|
/// Return a copy with x/series taken from [csv]; keeps [source].
|
|
ChartSpec withCsv(String csv) {
|
|
final parsed = parseCsv(csv);
|
|
final colorsByLabel = x.isEmpty
|
|
? const <String, String?>{}
|
|
: <String, String?>{
|
|
for (var i = 0; i < x.length; i++)
|
|
x[i]: i < rowColors.length ? rowColors[i] : null,
|
|
};
|
|
return copyWith(
|
|
x: parsed.$1,
|
|
rowColors: [
|
|
for (var i = 0; i < parsed.$1.length; i++)
|
|
colorsByLabel[parsed.$1[i]] ??
|
|
(i < rowColors.length ? rowColors[i] : null),
|
|
],
|
|
series: [
|
|
for (var i = 0; i < parsed.$2.length; i++)
|
|
ChartSeries(
|
|
name: parsed.$2[i].name,
|
|
data: parsed.$2[i].data,
|
|
color: i < series.length ? series[i].color : null,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Parse CSV text into (x labels, series). The first row is a header whose
|
|
/// first cell is ignored (the label column) and whose remaining cells are the
|
|
/// series names; each later row is `label, v1, v2, …`.
|
|
(List<String>, List<ChartSeries>) parseCsv(String csv) {
|
|
final lines = csv
|
|
.replaceAll('\r\n', '\n')
|
|
.split('\n')
|
|
.where((l) => l.trim().isNotEmpty)
|
|
.toList();
|
|
if (lines.isEmpty) return (const [], const []);
|
|
|
|
List<String> cells(String line) =>
|
|
line.split(',').map((c) => c.trim()).toList();
|
|
|
|
final header = cells(lines.first);
|
|
final seriesNames = header.length > 1 ? header.sublist(1) : <String>[];
|
|
final x = <String>[];
|
|
final seriesData = [for (final _ in seriesNames) <double>[]];
|
|
|
|
for (final line in lines.skip(1)) {
|
|
final row = cells(line);
|
|
if (row.isEmpty) continue;
|
|
x.add(row.first);
|
|
for (var i = 0; i < seriesNames.length; i++) {
|
|
final raw = (i + 1) < row.length ? row[i + 1] : '';
|
|
seriesData[i].add(double.tryParse(raw) ?? 0);
|
|
}
|
|
}
|
|
|
|
return (
|
|
x,
|
|
[
|
|
for (var i = 0; i < seriesNames.length; i++)
|
|
ChartSeries(name: seriesNames[i], data: seriesData[i]),
|
|
],
|
|
);
|
|
}
|