feature/app-theming-and-code-slides #2

Merged
brenno merged 4 commits from feature/app-theming-and-code-slides into main 2026-06-08 12:31:04 +00:00
7 changed files with 383 additions and 137 deletions
Showing only changes of commit dd54d36a60 - Show all commits

View file

@ -8,13 +8,22 @@ and the project aims to follow [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Source-code slides** — a dark "code sheet" with per-language syntax - **Source-code slides** — a "code sheet" with per-language syntax highlighting,
highlighting, stored as a fenced code block. stored as a fenced code block. Background and text colours are part of the style
- **Charts** — bar, line, and pie chart slides. Data is entered in an in-app grid profile, with a syntax-colouring toggle; turning it off renders the block in a
or imported from CSV; the spec is stored as JSON in a ```chart block. Data can single colour (e.g. green on black for a CRT-terminal look).
stay inline or be linked to a CSV in a separate `data/` directory. Rendered - **Charts** — bar, line, pie, and **spider/radar** chart slides. Data is entered
natively in-app (preview, presenter, PDF, PPTX) and as self-contained SVG in in an in-app grid or imported from CSV; the spec is stored as JSON in a ```chart
the HTML export. block. Data can stay inline or be linked to a CSV in a separate `data/`
directory. Rendered natively in-app (preview, presenter, PDF, PPTX) and as
self-contained SVG in the HTML export.
- Optional **min/max**: horizontal reference lines on bar/line charts, or a
fixed scale on spider/radar charts shown as a small legend beside the figure.
- **Legend hover** highlights the matching series (or pie slice). Line-chart
tooltips attach to the dot under the cursor (showing every overlapping dot),
and spider/radar points show a tooltip on hover too.
- **Custom theme colours** — every style-profile colour can be entered as a custom
hex value in addition to the presets.
- **Per-slide TLP classification** — each slide can carry its own Traffic Light - **Per-slide TLP classification** — each slide can carry its own Traffic Light
Protocol level; slides classified stricter than the level the deck is shown at Protocol level; slides classified stricter than the level the deck is shown at
are withheld when presenting and exporting. are withheld when presenting and exporting.

View file

@ -9,8 +9,8 @@ Built with Flutter for macOS, Windows, and Linux.
## Features ## Features
- **Structured slide editors** — dedicated editors per slide type: title, bullets, two-column bullets, bullets + image, single/two images, quote, table, section divider, image-only, video, audio, source code, charts, and free-form Markdown. - **Structured slide editors** — dedicated editors per slide type: title, bullets, two-column bullets, bullets + image, single/two images, quote, table, section divider, image-only, video, audio, source code, charts, and free-form Markdown.
- **Source-code slides** — a dark "code sheet" with syntax highlighting per language, stored as a fenced code block. - **Source-code slides** — a "code sheet" with per-language syntax highlighting, stored as a fenced code block. Background and text colours come from the style profile, with an optional syntax-colouring toggle (turn it off for a single-colour, CRT-terminal look).
- **Charts** — bar, line, and pie charts rendered natively (preview, presenter, PDF, PPTX) and as self-contained SVG in the HTML export. Data is entered in an in-app grid or imported from CSV; the spec is stored as JSON in the Markdown, with optional linking to a CSV kept in a tidy `data/` directory. - **Charts** — bar, line, pie, and spider/radar charts rendered natively (preview, presenter, PDF, PPTX) and as self-contained SVG in the HTML export. Data is entered in an in-app grid or imported from CSV; the spec is stored as JSON in the Markdown, with optional linking to a CSV kept in a tidy `data/` directory. Optional min/max draw reference lines (bar/line) or fix the scale (radar); legend hover highlights a series, and line tooltips pick the dot under the cursor.
- **Live preview** — see each slide rendered as you edit, with inline Markdown, footers, and TLP (Traffic Light Protocol) marking. Free-Markdown slides render fenced code with syntax highlighting and `$…$` / `$$…$$` LaTeX math. - **Live preview** — see each slide rendered as you edit, with inline Markdown, footers, and TLP (Traffic Light Protocol) marking. Free-Markdown slides render fenced code with syntax highlighting and `$…$` / `$$…$$` LaTeX math.
- **Traffic Light Protocol** — a deck-wide classification plus an optional **per-slide TLP level**; slides classified stricter than the level the deck is shown at are automatically withheld, both when presenting and exporting. - **Traffic Light Protocol** — a deck-wide classification plus an optional **per-slide TLP level**; slides classified stricter than the level the deck is shown at are automatically withheld, both when presenting and exporting.
- **Fullscreen presenter** — keyboard-driven navigation, presenter view, blank screen, auto-advance, and a slide-grid overview. - **Fullscreen presenter** — keyboard-driven navigation, presenter view, blank screen, auto-advance, and a slide-grid overview.
@ -20,7 +20,7 @@ Built with Flutter for macOS, Windows, and Linux.
- **Import / export** — round-trips Marp Markdown, imports existing slides, and exports to PDF, PPTX (with speaker notes), and a self-contained offline HTML deck (code highlighting, math, charts, and mermaid diagrams render in the browser). Decks are saved as a self-contained package with copied assets. - **Import / export** — round-trips Marp Markdown, imports existing slides, and exports to PDF, PPTX (with speaker notes), and a self-contained offline HTML deck (code highlighting, math, charts, and mermaid diagrams render in the browser). Decks are saved as a self-contained package with copied assets.
- **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, multi-select with bulk copy-to-another-deck / delete / skip, and tabbed multi-deck editing. `Ctrl/Cmd+O` opens, `Ctrl/Cmd+S` saves. - **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, multi-select with bulk copy-to-another-deck / delete / skip, and tabbed multi-deck editing. `Ctrl/Cmd+O` opens, `Ctrl/Cmd+S` saves.
- **Crash recovery** — automatic snapshots so work survives an unexpected exit. - **Crash recovery** — automatic snapshots so work survives an unexpected exit.
- **Theming** — customizable deck style profiles and app appearance (including a dark interface), a bundled Marp CSS theme (`assets/themes/ocideck.css`), and a bundled EB Garamond font (no network fetch). - **Theming** — customizable deck style profiles (deck and source-code colours via presets or custom hex, fonts, logo, footer) and app appearance (including a dark interface), a bundled Marp CSS theme (`assets/themes/ocideck.css`), and a bundled EB Garamond font (no network fetch).
- **Localized** — Dutch, English, Italian, German, French, Spanish, Frisian, and Papiamento. - **Localized** — Dutch, English, Italian, German, French, Spanish, Frisian, and Papiamento.
## Requirements ## Requirements

View file

@ -135,6 +135,9 @@ JSON heeft deze velden (met standaardwaarden):
| `titleBackgroundColor` | `#1C2B47` | Achtergrond titelslide. | | `titleBackgroundColor` | `#1C2B47` | Achtergrond titelslide. |
| `titleTextColor` | `#FFFFFF` | Tekst op titel-/sectieslide. | | `titleTextColor` | `#FFFFFF` | Tekst op titel-/sectieslide. |
| `sectionBackgroundColor` | `#2E7D64` | Achtergrond sectieslide. | | `sectionBackgroundColor` | `#2E7D64` | Achtergrond sectieslide. |
| `codeBackgroundColor` | `#282C34` | Achtergrond van broncode-slides. |
| `codeTextColor` | `#ABB2BF` | Tekstkleur van broncode-slides. |
| `codeHighlightSyntax` | `true` | Syntaxkleuring aan/uit. Uit = alles in één kleur (bijv. groen op zwart voor een CRT-look). |
| `logoPath` | `null` | Pad naar logo (relatief in `logos/`). | | `logoPath` | `null` | Pad naar logo (relatief in `logos/`). |
| `logoPosition` | `bottom-right` | `top-left`/`top-right`/`bottom-left`/`bottom-right`. | | `logoPosition` | `bottom-right` | `top-left`/`top-right`/`bottom-left`/`bottom-right`. |
| `logoSize` | `96` | Logogrootte in px. | | `logoSize` | `96` | Logogrootte in px. |
@ -314,15 +317,31 @@ openen wordt die weer ingelezen.
````markdown ````markdown
```chart ```chart
{ {
"type": "bar", // bar | line | pie "type": "bar", // bar | line | pie | radar
"title": "Omzet", "title": "Omzet",
"source": "data/omzet.csv", // optioneel; anders inline x/series "source": "data/omzet.csv", // optioneel; anders inline x/series
"x": ["Q1", "Q2"], "x": ["Q1", "Q2"],
"series": [ { "name": "2025", "data": [10, 14] } ] "rowColors": ["#003399", "#FFCC00"], // optioneel; kleur per label (cirkel/radar)
"minBound": 0, // optioneel; niet bij pie
"maxBound": 20, // optioneel; niet bij pie
"series": [ { "name": "2025", "data": [10, 14], "color": "#2563EB" } ]
} }
``` ```
```` ````
Velden:
- `type``bar`, `line`, `pie` of `radar` (spider). Standaard `bar`.
- `x` — labels; bij `pie`/`radar` zijn dit de segmenten/assen (radar heeft er
minstens drie nodig).
- `series` — genoemde reeksen met `data` (uitgelijnd op `x`) en optioneel een
`color` (hex). `pie` toont maximaal de eerste twee reeksen.
- `rowColors` — optionele kleur per label (gebruikt door `pie`/`radar`).
- `minBound` / `maxBound` — optioneel en alleen voor niet-`pie`. Bij `bar`/`line`
zijn het horizontale **referentielijnen**; bij `radar` bepalen ze de **schaal**
(binnenste/buitenste ring) met een gelijkmatige verdeling. Worden weggelaten
bij `pie`.
### Afbeeldingsgrootte (`imageSize`) ### Afbeeldingsgrootte (`imageSize`)
Eén integer-veld met typeafhankelijke betekenis: bij `image`/`title`/`quote` het Eén integer-veld met typeafhankelijke betekenis: bij `image`/`title`/`quote` het
achtergrond-percentage (`![bg N%]`), bij `split` de paneelbreedte (geklemd achtergrond-percentage (`![bg N%]`), bij `split` de paneelbreedte (geklemd

View file

@ -20,8 +20,9 @@ Marp tools.
Add a slide and pick a type: **title**, **section** divider, **bullets**, **two Add a slide and pick a type: **title**, **section** divider, **bullets**, **two
bullet columns**, **bullets + image**, **two images**, **large image**, **video**, bullet columns**, **bullets + image**, **two images**, **large image**, **video**,
**audio**, **quote**, **table**, **source code**, **chart**, and **free Markdown**. **audio**, **quote**, **table**, **source code**, **chart** (bar, line, pie, or
Each type has a dedicated editor on the left and a live preview on the right. spider/radar), and **free Markdown**. Each type has a dedicated editor on the left
and a live preview on the right.
Text fields support inline Markdown (`**bold**`, `*italic*`, `` `code` ``, Text fields support inline Markdown (`**bold**`, `*italic*`, `` `code` ``,
`[links](…)`). Free-Markdown slides also render fenced code with syntax `[links](…)`). Free-Markdown slides also render fenced code with syntax
@ -30,19 +31,30 @@ highlighting and `$…$` / `$$…$$` LaTeX math.
### Source-code slides ### Source-code slides
Choose a programming language for syntax highlighting (or "plain text") and paste Choose a programming language for syntax highlighting (or "plain text") and paste
your code. It renders as a dark "code sheet". Stored as a fenced code block in the your code. It renders as a "code sheet" whose background and text colour come from
Markdown. the active **style profile**. Turn **syntax colouring** off to show the whole block
in a single colour — e.g. bright green on black for a classic CRT-terminal look.
Stored as a fenced code block in the Markdown.
### Charts ### Charts
Pick a type (**bar**, **line**, **pie**) and a title, then enter data in the grid: Pick a type (**bar**, **line**, **pie**, or **spider/radar**) and a title, then
the first column is the labels, each further column is a named series. Use **Row** enter data in the grid: the first column is the labels, each further column is a
and **Series** to add data; the small ✕ removes a row/column. named series. Use **Row** and **Series** to add data; the small ✕ removes a
row/column. Each series and (for pie/radar) each label can be given its own colour.
- **CSV import** — click **CSV importeren**. You can either keep the data **in the - **CSV import** — click **CSV importeren**. You can either keep the data **in the
slide** (inline) or store it **as a CSV file**. A linked CSV lives in the deck's slide** (inline) or store it **as a CSV file**. A linked CSV lives in the deck's
`data/` directory and stays the source of truth (edit it in a spreadsheet); the `data/` directory and stays the source of truth (edit it in a spreadsheet); the
grid then shows it read-only until you **Ontkoppelen** (unlink). grid then shows it read-only until you **Ontkoppelen** (unlink).
- **Min/max** (optional, bar/line/radar) — on bar and line charts these draw
horizontal **reference lines**; on a spider/radar chart they fix the **scale**
(centre to outer ring), shown as evenly spaced values in a small legend beside
the chart. Leave them empty to scale automatically.
- **Reading values** — hovering a legend entry highlights its series (or pie
slice). On a line chart the tooltip belongs to the dot under the cursor and
shows every overlapping dot at once; on a spider/radar chart hovering a point
shows its value in a tooltip too.
- Charts render in the preview, presenter, PDF, and PPTX, and as inline SVG in the - Charts render in the preview, presenter, PDF, and PPTX, and as inline SVG in the
HTML export. HTML export.
@ -100,8 +112,10 @@ Export to:
## Theming and language ## Theming and language
- **Style profiles** control deck colours, fonts, logo, and footer. The bundled - **Style profiles** control deck colours (including the source-code background and
Marp theme is `assets/themes/ocideck.css`. text, with an optional syntax-colouring toggle), fonts, logo, and footer. Every
colour can be picked from the presets or entered as a custom hex value. The
bundled Marp theme is `assets/themes/ocideck.css`.
- **App appearance** (including a dark interface) is configurable in settings. - **App appearance** (including a dark interface) is configurable in settings.
- The interface is available in Dutch, English, Italian, German, French, Spanish, - The interface is available in Dutch, English, Italian, German, French, Spanish,
Frisian, and Papiamento. Frisian, and Papiamento.

View file

@ -480,14 +480,21 @@ class MarpHtmlService {
); );
} }
// Scale labels up the top spoke: lo at the centre, hi at the outer ring. // Scale legend on the right: hi at the top down to lo at the bottom, so the
for (var k = 0; k <= ticks; k++) { // figure itself stays clean.
final legendX = cx + radius + 30;
for (var k = ticks; k >= 0; k--) {
final value = lo + span * k / ticks; final value = lo + span * k / ticks;
final y = cy - radius * k / ticks; final y = (cy - radius) + (2 * radius) * (ticks - k) / ticks;
b.write( b
'<text x="${cx + 6}" y="${y - 2}" font-size="11" ' ..write(
'fill="#94a3b8">${_num(value)}</text>', '<line x1="$legendX" y1="$y" x2="${legendX + 8}" y2="$y" '
); 'stroke="#cbd5e1" stroke-width="1"/>',
)
..write(
'<text x="${legendX + 12}" y="${y + 4}" font-size="12" '
'fill="#94a3b8">${_num(value)}</text>',
);
} }
// Spokes and axis labels. // Spokes and axis labels.

View file

@ -2212,6 +2212,10 @@ class _ChartPreviewState extends State<_ChartPreview> {
/// slice (category) index for pie charts. Null when nothing is hovered. /// slice (category) index for pie charts. Null when nothing is hovered.
int? _hovered; int? _hovered;
/// The radar vertex under the pointer, used to draw its tooltip. Null when not
/// hovering a point.
({int series, int entry, double value, Offset offset})? _radarTouch;
void _setHover(int? index) { void _setHover(int? index) {
if (_hovered != index) setState(() => _hovered = index); if (_hovered != index) setState(() => _hovered = index);
} }
@ -2894,133 +2898,247 @@ class _ChartPreviewState extends State<_ChartPreview> {
} }
final grid = textColor.withValues(alpha: 0.18); final grid = textColor.withValues(alpha: 0.18);
final scale = radarScale(spec); final scale = radarScale(spec);
final bg = _hexColor(profile.slideBackgroundColor);
return Padding( return Padding(
padding: EdgeInsets.symmetric(horizontal: w * 0.06, vertical: w * 0.012), padding: EdgeInsets.symmetric(horizontal: w * 0.03, vertical: w * 0.012),
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// A square keeps fl_chart's centre/radius predictable so the tick // Reserve a slim column on the right for the scale legend, then keep
// labels we overlay line up exactly with its grid rings. // the chart square so fl_chart's centre/radius stay predictable.
final side = math.min(constraints.maxWidth, constraints.maxHeight); final legendWidth = w * 0.075;
final centerOffset = side / 2; final available = constraints.maxWidth - legendWidth - w * 0.02;
final radius = centerOffset * 0.8; // matches RadarChartPainter final side = math.max(
final tickStyle = _applyFont( 0.0,
font, math.min(available, constraints.maxHeight),
TextStyle(
fontSize: w * 0.012 * _labelScale,
color: textColor.withValues(alpha: 0.6),
fontWeight: FontWeight.w600,
),
); );
return Center( return Row(
child: SizedBox( crossAxisAlignment: CrossAxisAlignment.center,
width: side, children: [
height: side, Expanded(
child: Stack( child: Center(
children: [ child: SizedBox(
Positioned.fill( width: side,
child: RadarChart( height: side,
RadarChartData( child: Stack(
dataSets: [ children: [
for (var si = 0; si < spec.series.length; si++) Positioned.fill(
RadarDataSet( child: RadarChart(
dataEntries: [ RadarChartData(
for (var xi = 0; xi < spec.x.length; xi++) dataSets: [
RadarEntry( for (var si = 0; si < spec.series.length; si++)
value: xi < spec.series[si].data.length RadarDataSet(
? spec.series[si].data[xi] dataEntries: [
: 0, 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 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,
),
], ],
fillColor: _seriesDisplayColor( radarShape: RadarShape.polygon,
spec.series[si], radarBackgroundColor: Colors.transparent,
si, radarBorderData: BorderSide(color: grid, width: 1),
).withValues(alpha: _dimmed(si) ? 0.04 : 0.16), gridBorderData: BorderSide(color: grid, width: 1),
borderColor: _seriesDisplayColor( tickBorderData: BorderSide(color: grid, width: 1),
spec.series[si], tickCount: scale.ticks,
si, isMinValueAtCenter: true,
// The scale now lives in a side legend, so hide
// fl_chart's in-chart ring numbers.
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: true,
touchSpotThreshold: (w * 0.02).clamp(8.0, 24.0)
.toDouble(),
mouseCursorResolver: (event, response) =>
_radarSpotFrom(response, spec) == null
? SystemMouseCursors.basic
: SystemMouseCursors.click,
touchCallback: (event, response) {
final next =
event.isInterestedForInteractions
? _radarSpotFrom(response, spec)
: null;
if (next != _radarTouch) {
setState(() => _radarTouch = next);
}
},
), ),
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 duration: Duration.zero,
// 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), if (_radarTouch != null)
), _radarTooltip(spec, side, _radarTouch!),
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,
),
),
),
),
],
), ),
), SizedBox(
width: legendWidth,
child: _radarScaleLegend(scale, textColor),
),
],
); );
}, },
), ),
); );
} }
/// Extract the touched real-series vertex from a radar touch response,
/// ignoring the invisible scale anchor dataset.
({int series, int entry, double value, Offset offset})? _radarSpotFrom(
RadarTouchResponse? response,
ChartSpec spec,
) {
final spot = response?.touchedSpot;
if (spot == null) return null;
if (spot.touchedDataSetIndex < 0 ||
spot.touchedDataSetIndex >= spec.series.length) {
return null; // the anchor dataset, or out of range
}
return (
series: spot.touchedDataSetIndex,
entry: spot.touchedRadarEntryIndex,
value: spot.touchedRadarEntry.value,
offset: spot.offset,
);
}
/// A small floating tooltip for the hovered radar vertex, like the other
/// charts: the axis label, the series name and the value.
Widget _radarTooltip(
ChartSpec spec,
double side,
({int series, int entry, double value, Offset offset}) touch,
) {
final axis = touch.entry >= 0 && touch.entry < spec.x.length
? spec.x[touch.entry]
: '';
final series = touch.series < spec.series.length
? spec.series[touch.series].name
: '';
final label = series.isEmpty ? 'Reeks ${touch.series + 1}' : series;
final onLeftHalf = touch.offset.dx <= side / 2;
return Positioned(
left: onLeftHalf ? (touch.offset.dx + w * 0.012) : null,
right: onLeftHalf ? null : (side - touch.offset.dx + w * 0.012),
top: (touch.offset.dy - w * 0.03).clamp(0.0, math.max(0.0, side - 1)),
child: IgnorePointer(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: side * 0.6),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: w * 0.012,
vertical: w * 0.006,
),
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
borderRadius: BorderRadius.circular(w * 0.008),
boxShadow: const [
BoxShadow(color: Color(0x33000000), blurRadius: 6),
],
),
child: Text(
'${axis.isEmpty ? '' : '$axis\n'}$label: ${_fmtNum(touch.value)}',
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: _tooltipStyle(),
),
),
),
),
);
}
/// Vertical scale legend shown to the right of a radar chart: the tick values
/// from the outer ring (top) down to the centre (bottom), in a small font.
Widget _radarScaleLegend(
({double lo, double hi, int ticks}) scale,
Color textColor,
) {
final style = _applyFont(
font,
TextStyle(
fontSize: w * 0.012 * _labelScale,
color: textColor.withValues(alpha: 0.62),
fontWeight: FontWeight.w600,
),
);
final tickColor = textColor.withValues(alpha: 0.3);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var k = scale.ticks; k >= 0; k--) ...[
if (k != scale.ticks) SizedBox(height: w * 0.018 * _labelScale),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: w * 0.012, height: 1, color: tickColor),
SizedBox(width: w * 0.006),
Flexible(
child: Text(
_fmtNum(scale.lo + (scale.hi - scale.lo) * k / scale.ticks),
style: style,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
);
}
/// Resolves the radar scale: a low/high pair plus an even tick count. Honours /// Resolves the radar scale: a low/high pair plus an even tick count. Honours
/// the optional [ChartSpec.minBound]/[maxBound] and otherwise rounds the data /// the optional [ChartSpec.minBound]/[maxBound] and otherwise rounds the data
/// range to a tidy scale so the rings read as round numbers. /// range to a tidy scale so the rings read as round numbers.

View file

@ -189,8 +189,8 @@ void main() {
// A crowded stack uses a smaller font than a single tooltip. // A crowded stack uses a smaller font than a single tooltip.
final single = touch.touchTooltipData.getTooltipItems([spots.first]); final single = touch.touchTooltipData.getTooltipItems([spots.first]);
expect( expect(
items[0]!.textStyle!.fontSize!, items[0]!.textStyle.fontSize!,
lessThan(single.single!.textStyle!.fontSize!), lessThan(single.single!.textStyle.fontSize!),
); );
}); });
@ -324,12 +324,91 @@ void main() {
final anchor = radar.data.dataSets.last.dataEntries.map((e) => e.value); 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), 0);
expect(anchor.reduce((a, b) => a > b ? a : b), 10); expect(anchor.reduce((a, b) => a > b ? a : b), 10);
// Evenly spaced scale labels are drawn (0..10). // The scale is shown in the side legend (0..10), not painted in the chart.
expect(radar.data.ticksTextStyle?.color, Colors.transparent);
expect(find.text('0'), findsWidgets); expect(find.text('0'), findsWidgets);
expect(find.text('10'), findsOneWidget); expect(find.text('10'), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('radar shows a tooltip for the hovered point', (tester) async {
const spec = ChartSpec(
type: ChartType.radar,
x: ['Snelheid', 'Kracht', 'Uithouding'],
series: [
ChartSeries(name: 'Alpha', data: [3, 4, 5]),
ChartSeries(name: 'Beta', data: [5, 2, 3]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final radar = tester.widget<RadarChart>(find.byType(RadarChart));
final touch = radar.data.radarTouchData;
expect(touch.enabled, isTrue);
// Simulate hovering the second axis of the Beta series.
final dataSet = radar.data.dataSets[1];
final entry = dataSet.dataEntries[1];
touch.touchCallback!(
const FlPointerHoverEvent(PointerHoverEvent()),
RadarTouchResponse(
touchLocation: const Offset(20, 20),
touchedSpot: RadarTouchedSpot(
dataSet,
1,
entry,
1,
const FlSpot(1, 2),
const Offset(20, 20),
),
),
);
await tester.pump();
expect(find.text('Kracht\nBeta: 2'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('radar ignores touches on the invisible scale anchor', (
tester,
) async {
const spec = ChartSpec(
type: ChartType.radar,
x: ['A', 'B', 'C'],
series: [
ChartSeries(name: 'Alpha', data: [3, 4, 5]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final radar = tester.widget<RadarChart>(find.byType(RadarChart));
final anchorIndex = radar.data.dataSets.length - 1; // the anchor dataset
final anchor = radar.data.dataSets[anchorIndex];
radar.data.radarTouchData.touchCallback!(
const FlPointerHoverEvent(PointerHoverEvent()),
RadarTouchResponse(
touchLocation: Offset.zero,
touchedSpot: RadarTouchedSpot(
anchor,
anchorIndex,
anchor.dataEntries.first,
0,
const FlSpot(0, 0),
Offset.zero,
),
),
);
await tester.pump();
// No tooltip for the anchor: only the legend value "5" exists.
expect(find.textContaining(': '), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('radar chart asks for at least three labels', (tester) async { testWidgets('radar chart asks for at least three labels', (tester) async {
const spec = ChartSpec( const spec = ChartSpec(
type: ChartType.radar, type: ChartType.radar,