2026-06-07 11:42:44 +02:00
|
|
|
import 'dart:convert';
|
|
|
|
|
|
2026-06-07 11:45:48 +02:00
|
|
|
/// 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';
|
|
|
|
|
|
2026-06-07 11:42:44 +02:00
|
|
|
/// Supported chart kinds for a chart slide.
|
|
|
|
|
enum ChartType { bar, line, pie }
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
const ChartSeries({required this.name, required this.data});
|
|
|
|
|
|
|
|
|
|
Map<String, dynamic> toJson() => {'name': name, 'data': data};
|
|
|
|
|
|
|
|
|
|
factory ChartSeries.fromJson(Map<String, dynamic> json) => ChartSeries(
|
|
|
|
|
name: (json['name'] ?? '').toString(),
|
|
|
|
|
data: [
|
|
|
|
|
for (final v in (json['data'] as List? ?? const []))
|
|
|
|
|
(v as num?)?.toDouble() ?? 0,
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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<ChartSeries> series;
|
|
|
|
|
|
|
|
|
|
const ChartSpec({
|
|
|
|
|
this.type = ChartType.bar,
|
|
|
|
|
this.title = '',
|
|
|
|
|
this.source,
|
|
|
|
|
this.x = const [],
|
|
|
|
|
this.series = const [],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
bool get hasInlineData => x.isNotEmpty && series.isNotEmpty;
|
|
|
|
|
|
|
|
|
|
ChartSpec copyWith({
|
|
|
|
|
ChartType? type,
|
|
|
|
|
String? title,
|
|
|
|
|
String? source,
|
|
|
|
|
bool clearSource = false,
|
|
|
|
|
List<String>? x,
|
|
|
|
|
List<ChartSeries>? series,
|
|
|
|
|
}) => ChartSpec(
|
|
|
|
|
type: type ?? this.type,
|
|
|
|
|
title: title ?? this.title,
|
|
|
|
|
source: clearSource ? null : (source ?? this.source),
|
|
|
|
|
x: x ?? this.x,
|
|
|
|
|
series: series ?? this.series,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/// 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,
|
|
|
|
|
x: [for (final v in (data['x'] as List? ?? const [])) v.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;
|
|
|
|
|
final dropData = forStorage && source != null;
|
|
|
|
|
if (!dropData) {
|
|
|
|
|
if (x.isNotEmpty) map['x'] = x;
|
|
|
|
|
if (series.isNotEmpty) {
|
|
|
|
|
map['series'] = [for (final s in series) s.toJson()];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
return copyWith(x: parsed.$1, series: parsed.$2);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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 []);
|
|
|
|
|
|
Add project docs, EUPL licence, and open-source licence check
Documentation & licensing:
- Add the EUPL-1.2 licence (LICENSE.md) and set the project licence; refresh
the README (name origin wink, updated feature list, documentation index).
- Add CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, CHANGELOG, AUTHORS, and
THIRD_PARTY_NOTICES, plus docs/ (ARCHITECTURE, BUILD, USER_GUIDE, SHORTCUTS,
LICENSE_COMPLIANCE) and .github/ (CI workflow, issue/PR templates).
- Bring docs/FILE_FORMAT.md in line with current behaviour (code & chart
slides, per-slide TLP comment, annotation .ink.json sidecar, chart data/ CSVs).
Open-source compliance:
- Add tool/check_licenses.dart and a `make licenses` target (wired into
check-full and CI) that verifies every resolved dependency uses a recognised
open-source licence. A scan of all 151 packages and bundled assets found only
OSI-approved licences.
Charts (Fase 1.1):
- Replace the chart CSV textarea with an in-app editable data grid (editable
series/labels/values, add/remove row & column, read-only when linked).
- Centralize the linked-CSV directory name (`data/`) in a shared constant.
Also normalize formatting repo-wide with `dart format` and fix one
curly-braces lint, so `make check` and CI are green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 12:19:56 +02:00
|
|
|
List<String> cells(String line) =>
|
|
|
|
|
line.split(',').map((c) => c.trim()).toList();
|
2026-06-07 11:42:44 +02:00
|
|
|
|
|
|
|
|
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]),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|