Files
kanimaji-dart/lib/widget.dart
T
2026-02-22 01:46:34 +09:00

528 lines
18 KiB
Dart

// // A Flutter widget that renders an animated single-kanji stroke drawing using
// // the project's SVG path parser. This widget loads the Kanji SVG from
// // `assets/kanjivg/kanji/<hex>.svg` (the same assets used by the exporter),
// // parses the path `d` attributes, converts them into `ui.Path` objects and
// // animates the stroke draw using an AnimationController.
// //
// // Notes:
// // - This implementation uses the repository's SVG path parser (`parsePath`)
// // and the custom `Path` representation in `lib/svg/path.dart`. We convert
// // those into Flutter `ui.Path` by sampling points along the sampled SVG path.
// // - The SVGs in KanjiVG typically use a 109x109 viewBox. We detect the
// // `viewBox` attribute (if present) and scale paths to fit the widget size.
// //
// // Public API (Kanimaji widget parameters):
// // - `kanji` (required): the single character Kanji to display.
// // - `size`: target width/height of the widget (square).
// // - `strokeColor`, `strokeUnfilledColor`, `strokeWidth`.
// // - `backgroundColor`.
// // - `speed`: multiplier to speed up (>1) or slow down (<1) the animation.
// // - `loop`: whether to repeat the animation.
// // - `showBrush`: whether to show a small circular "brush" tip following the stroke.
// // - `durationOverride`: optionally override the computed animation duration.
// //
// // Example:
// // Kanimaji(
// // kanji: '水',
// // size: 240,
// // strokeColor: Colors.black,
// // strokeUnfilledColor: Colors.grey.shade300,
// // strokeWidth: 3.0,
// // backgroundColor: Colors.white,
// // speed: 1.0,
// // loop: true,
// // );
// import 'dart:math' as math;
// import 'dart:ui' as ui;
// import 'package:flutter/material.dart';
// import 'package:flutter/services.dart' show rootBundle;
// import 'package:xml/xml.dart';
// import 'svg/parser.dart' show parsePath;
// import 'svg/path.dart' as svgpath;
// import 'point.dart' as kpt;
// import 'animator.dart' show strokeLengthToDuration;
// class Kanimaji extends StatefulWidget {
// final String kanji;
// final double size;
// final Color strokeColor;
// final Color strokeUnfilledColor;
// final double strokeWidth;
// final Color backgroundColor;
// final double speed;
// final bool loop;
// final bool showBrush;
// final Duration? durationOverride;
// const Kanimaji({
// super.key,
// required this.kanji,
// this.size = 150,
// this.strokeColor = Colors.black,
// this.strokeUnfilledColor = const Color(0xFFEEEEEE),
// this.strokeWidth = 3.0,
// this.backgroundColor = Colors.transparent,
// this.speed = 1.0,
// this.loop = true,
// this.showBrush = true,
// this.durationOverride,
// });
// @override
// State<Kanimaji> createState() => _KanimajiState();
// }
// class _KanimajiState extends State<Kanimaji> with SingleTickerProviderStateMixin {
// bool _loaded = false;
// String? _error;
// late AnimationController _controller;
// // Converted UI paths and per-path lengths
// List<ui.Path> _paths = [];
// List<double> _pathLengths = [];
// // per-stroke durations (in seconds) computed using animator.strokeLengthToDuration
// List<double> _pathDurations = [];
// // viewBox width/height from svg for scaling
// double _viewBoxWidth = 109.0;
// double _viewBoxHeight = 109.0;
// // total length summation (for drawing metrics)
// double _totalLength = 0.0;
// @override
// void initState() {
// super.initState();
// // Dummy controller; real duration will be set after loading
// _controller = AnimationController(vsync: this);
// // Add a single listener once to avoid duplicates when reconfiguring the controller.
// _controller.addListener(_onTick);
// _loadAndParseSvg();
// }
// @override
// void didUpdateWidget(covariant Kanimaji oldWidget) {
// super.didUpdateWidget(oldWidget);
// // If kanji changed, re-load
// if (oldWidget.kanji != widget.kanji) {
// _loadAndParseSvg();
// } else if (oldWidget.speed != widget.speed ||
// oldWidget.durationOverride != widget.durationOverride ||
// oldWidget.loop != widget.loop) {
// // Update controller if animation parameters changed
// _configureController();
// }
// }
// void _onTick() {
// if (mounted) setState(() {});
// }
// @override
// void dispose() {
// _controller.removeListener(_onTick);
// _controller.dispose();
// super.dispose();
// }
// String _codepointHex(String s) {
// // Support multi-code-unit characters by taking the first Unicode rune
// final runes = s.runes.toList();
// if (runes.isEmpty) return '00000';
// return runes[0].toRadixString(16).padLeft(5, '0');
// }
// Future<void> _loadAndParseSvg() async {
// setState(() {
// _loaded = false;
// _error = null;
// _paths = [];
// _pathLengths = [];
// _pathDurations = [];
// _totalLength = 0.0;
// });
// final hex = _codepointHex(widget.kanji);
// final assetPath = 'assets/kanjivg/kanji/$hex.svg';
// try {
// final svgString = await rootBundle.loadString(assetPath);
// final doc = XmlDocument.parse(svgString);
// // parse viewBox: "minX minY width height"
// final viewBoxAttr = doc.rootElement.getAttribute('viewBox');
// if (viewBoxAttr != null) {
// final parts = viewBoxAttr.split(RegExp(r'[\s,]+')).where((p) => p.isNotEmpty).toList();
// if (parts.length >= 4) {
// final w = double.tryParse(parts[2]);
// final h = double.tryParse(parts[3]);
// if (w != null && h != null && w > 0 && h > 0) {
// _viewBoxWidth = w;
// _viewBoxHeight = h;
// }
// }
// } else {
// // fallback to width/height attributes if present (may include "px")
// final widthAttr = doc.rootElement.getAttribute('width');
// final heightAttr = doc.rootElement.getAttribute('height');
// if (widthAttr != null && heightAttr != null) {
// final w = double.tryParse(widthAttr.replaceAll('px', '').trim());
// final h = double.tryParse(heightAttr.replaceAll('px', '').trim());
// if (w != null && h != null && w > 0 && h > 0) {
// _viewBoxWidth = w;
// _viewBoxHeight = h;
// }
// }
// }
// // Find all <path ... d="..."> elements inside the SVG. KanjiVG groups strokes in <g>.
// final pathElements = doc.findAllElements('path', namespace: doc.rootElement.name.namespaceUri).toList();
// if (pathElements.isEmpty) {
// throw Exception('No <path> elements found in $assetPath');
// }
// final uiPaths = <ui.Path>[];
// final lengths = <double>[];
// for (final p in pathElements) {
// final d = p.getAttribute('d');
// if (d == null || d.trim().isEmpty) continue;
// // Parse the SVG path using existing parser
// final svgParsed = parsePath(d); // returns svgpath.Path (project type)
// // Convert to ui.Path using direct segment conversion (no sampling)
// final uiPath = _svgPathToUiPath(svgParsed);
// if (uiPath == null) continue;
// // Compute its drawing length using PathMetrics (for extractPath)
// final pm = uiPath.computeMetrics();
// double pathLen = 0.0;
// for (final metric in pm) {
// pathLen += metric.length;
// }
// // If the path metric yields zero (rare), skip
// if (pathLen <= 0) continue;
// // Compute logical stroke length using the parser (precise geometry) and convert to duration
// final double logicalLen = svgParsed.size(error: 1e-8).toDouble();
// final double durationSec = strokeLengthToDuration(logicalLen);
// uiPaths.add(uiPath);
// lengths.add(pathLen);
// _pathDurations.add(durationSec);
// _totalLength += pathLen;
// }
// if (uiPaths.isEmpty) {
// throw Exception('No drawable paths found in $assetPath');
// }
// setState(() {
// _paths = uiPaths;
// _pathLengths = lengths;
// });
// _configureController();
// setState(() {
// _loaded = true;
// });
// } catch (e) {
// setState(() {
// _error = 'Error loading/ parsing $assetPath: $e';
// _loaded = true; // mark loaded so we can show error
// });
// }
// }
// // Convert a parsed SVG path (project Path) into a Flutter ui.Path without sampling.
// // Uses the concrete segment types (Move, Line, CubicBezier, QuadraticBezier, Arc, Close)
// // and maps them to the corresponding ui.Path methods for native, accurate drawing.
// ui.Path? _svgPathToUiPath(svgpath.Path svgP) {
// try {
// final ui.Path p = ui.Path();
// bool started = false;
// for (final seg in svgP) {
// if (seg is svgpath.Move) {
// p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble());
// started = true;
// } else if (seg is svgpath.Line) {
// if (!started) {
// p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble());
// started = true;
// }
// p.lineTo(seg.end.x.toDouble(), seg.end.y.toDouble());
// } else if (seg is svgpath.Close) {
// p.close();
// } else if (seg is svgpath.CubicBezier) {
// if (!started) {
// p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble());
// started = true;
// }
// p.cubicTo(
// seg.control1.x.toDouble(),
// seg.control1.y.toDouble(),
// seg.control2.x.toDouble(),
// seg.control2.y.toDouble(),
// seg.end.x.toDouble(),
// seg.end.y.toDouble(),
// );
// } else if (seg is svgpath.QuadraticBezier) {
// if (!started) {
// p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble());
// started = true;
// }
// p.quadraticBezierTo(
// seg.control.x.toDouble(),
// seg.control.y.toDouble(),
// seg.end.x.toDouble(),
// seg.end.y.toDouble(),
// );
// } else if (seg is svgpath.Arc) {
// if (!started) {
// p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble());
// started = true;
// }
// // Map SVG Arc to Flutter arcToPoint. rotation is in degrees in svg; arcToPoint expects radians.
// final radius = Radius.elliptical(seg.radius.x.toDouble(), seg.radius.y.toDouble());
// p.arcToPoint(
// Offset(seg.end.x.toDouble(), seg.end.y.toDouble()),
// radius: radius,
// rotation: seg.rotation.toDouble() * (math.pi / 180.0),
// largeArc: seg.arc,
// clockwise: seg.sweep,
// );
// } else {
// // Unknown segment fallback: try to move to end
// try {
// p.moveTo(seg.end.x.toDouble(), seg.end.y.toDouble());
// started = true;
// } catch (_) {}
// }
// }
// return p;
// } catch (e) {
// return null;
// }
// }
// void _configureController() {
// // Use per-stroke durations computed from the parsed geometry (strokeLengthToDuration)
// // Sum durations and scale by widget.speed. If durationOverride is provided use it.
// final double totalSec = _pathDurations.fold(0.0, (a, b) => a + b);
// final double scaledTotalSec = (widget.speed <= 0) ? totalSec : (totalSec / widget.speed);
// final Duration duration = widget.durationOverride ??
// Duration(milliseconds: (math.max(0.001, scaledTotalSec) * 1000.0).round());
// final bool shouldRepeat = widget.loop;
// _controller.stop();
// _controller.duration = duration;
// if (shouldRepeat) {
// _controller.repeat();
// } else {
// _controller.forward(from: 0);
// }
// // Repaint handled by the single listener added in initState.
// }
// @override
// Widget build(BuildContext context) {
// if (!_loaded) {
// return SizedBox(
// width: widget.size,
// height: widget.size,
// child: Center(child: CircularProgressIndicator()),
// );
// }
// if (_error != null) {
// return SizedBox(
// width: widget.size,
// height: widget.size,
// child: Container(
// color: widget.backgroundColor,
// alignment: Alignment.center,
// child: Text(
// 'Error',
// style: TextStyle(color: Colors.red, fontSize: math.max(10, widget.size * 0.08)),
// ),
// ),
// );
// }
// return SizedBox(
// width: widget.size,
// height: widget.size,
// child: CustomPaint(
// painter: _KanimajiPainter(
// paths: _paths,
// pathLengths: _pathLengths,
// pathDurations: _pathDurations,
// progress: _controller.value,
// strokeColor: widget.strokeColor,
// strokeUnfilledColor: widget.strokeUnfilledColor,
// strokeWidth: widget.strokeWidth,
// backgroundColor: widget.backgroundColor,
// viewBoxWidth: _viewBoxWidth,
// viewBoxHeight: _viewBoxHeight,
// showBrush: widget.showBrush,
// ),
// ),
// );
// }
// }
// class _KanimajiPainter extends CustomPainter {
// final List<ui.Path> paths;
// final List<double> pathLengths;
// final List<double> pathDurations; // per-stroke durations in seconds
// final double progress; // 0..1
// final Color strokeColor;
// final Color strokeUnfilledColor;
// final double strokeWidth;
// final Color backgroundColor;
// final double viewBoxWidth;
// final double viewBoxHeight;
// final bool showBrush;
// _KanimajiPainter({
// required this.paths,
// required this.pathLengths,
// required this.pathDurations,
// required this.progress,
// required this.strokeColor,
// required this.strokeUnfilledColor,
// required this.strokeWidth,
// required this.backgroundColor,
// required this.viewBoxWidth,
// required this.viewBoxHeight,
// required this.showBrush,
// });
// @override
// void paint(Canvas canvas, Size size) {
// // Paint background
// final bgPaint = Paint()..color = backgroundColor;
// canvas.drawRect(Offset.zero & size, bgPaint);
// if (paths.isEmpty) return;
// // compute scale to fit viewBox into size, preserving aspect ratio
// final double sx = size.width / viewBoxWidth;
// final double sy = size.height / viewBoxHeight;
// final double scale = math.min(sx, sy);
// // compute translation to center the drawing
// final double dx = (size.width - viewBoxWidth * scale) / 2.0;
// final double dy = (size.height - viewBoxHeight * scale) / 2.0;
// canvas.save();
// canvas.translate(dx, dy);
// canvas.scale(scale, scale);
// // paints
// final Paint unfilledPaint = Paint()
// ..style = PaintingStyle.stroke
// ..strokeCap = StrokeCap.round
// ..strokeJoin = StrokeJoin.round
// ..strokeWidth = strokeWidth / scale // scale stroke width so it looks consistent
// ..color = strokeUnfilledColor;
// final Paint filledPaint = Paint()
// ..style = PaintingStyle.stroke
// ..strokeCap = StrokeCap.round
// ..strokeJoin = StrokeJoin.round
// ..strokeWidth = strokeWidth / scale
// ..color = strokeColor;
// // total animation time in seconds computed from durations
// final double totalTime = pathDurations.isEmpty ? 1.0 : pathDurations.fold(0.0, (a, b) => a + b);
// final double p = progress.clamp(0.0, 1.0);
// double elapsedTime = 0.0; // seconds accumulated so far
// for (int i = 0; i < paths.length; i++) {
// final ui.Path path = paths[i];
// final double len = pathLengths[i];
// final double dur = (i < pathDurations.length) ? pathDurations[i] : 0.0;
// final double startFraction = totalTime == 0 ? 0.0 : (elapsedTime / totalTime);
// final double endFraction = totalTime == 0 ? 1.0 : ((elapsedTime + dur) / totalTime);
// // Draw background (unfilled) fully for this stroke
// canvas.drawPath(path, unfilledPaint);
// // Determine coverage for this stroke based on global progress (time-based)
// double coverage = 0.0;
// if (p >= endFraction) {
// coverage = 1.0;
// } else if (p <= startFraction || dur <= 0.0) {
// coverage = 0.0;
// } else {
// coverage = (p - startFraction) / (endFraction - startFraction);
// }
// if (coverage > 0.0) {
// // extract subpath up to coverage * len (length-based extraction for drawing)
// final metrics = path.computeMetrics().toList();
// final ui.Path drawn = ui.Path();
// double remain = coverage * len;
// for (final metric in metrics) {
// if (remain <= 0) break;
// final take = math.min(metric.length, remain);
// final sub = metric.extractPath(0.0, take);
// drawn.addPath(sub, Offset.zero);
// remain -= take;
// }
// // draw filled portion
// canvas.drawPath(drawn, filledPaint);
// // brush tip
// if (showBrush && coverage > 0.0 && coverage < 1.0) {
// // find the tangent at the very end of the drawn part
// final metrics2 = path.computeMetrics().toList();
// double rem = coverage * len;
// for (final metric in metrics2) {
// if (rem <= 0) break;
// if (rem <= metric.length + 1e-9) {
// final tangent = metric.getTangentForOffset(rem.clamp(0.0, metric.length));
// if (tangent != null) {
// final Offset pos = tangent.position;
// final double brushRadius = (strokeWidth * 0.9) / scale;
// final Paint brushPaint = Paint()..color = strokeColor;
// canvas.drawCircle(pos, brushRadius, brushPaint);
// }
// break;
// } else {
// rem -= metric.length;
// }
// }
// }
// }
// elapsedTime += dur;
// }
// canvas.restore();
// }
// @override
// bool shouldRepaint(covariant _KanimajiPainter oldDelegate) {
// return oldDelegate.progress != progress ||
// oldDelegate.paths != paths ||
// oldDelegate.strokeColor != strokeColor ||
// oldDelegate.strokeUnfilledColor != strokeUnfilledColor ||
// oldDelegate.strokeWidth != strokeWidth ||
// oldDelegate.backgroundColor != backgroundColor;
// }
// }