feature/app-theming-and-code-slides #2
7 changed files with 383 additions and 137 deletions
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue