cyberhybridhub/lib/guid/guid_glyph_shape.dart

291 lines
9.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Deterministic irregular polygon + palette derived from a question UUID.
///
/// Parses the canonical 16-byte UUID (hyphens optional) and maps byte pairs to
/// polar vertices so the same id always produces the same glyph.
class GuidGlyphShape {
GuidGlyphShape._(this.bytes);
final Uint8List bytes;
/// Builds a shape from [guid]. Non-UUID strings fall back to a stable hash.
factory GuidGlyphShape.fromGuid(String guid) {
final Uint8List? parsed = tryParseUuidBytes(guid);
if (parsed != null) {
return GuidGlyphShape._(parsed);
}
return GuidGlyphShape._(_hashTo16Bytes(guid));
}
/// Vertex count in [5, 10], from the first UUID byte.
int get vertexCount => 5 + (bytes[0] % 6);
/// Unit-circle offsets (rough polygon); multiply by size when painting.
List<Offset> unitVertices() {
final int n = vertexCount;
final List<Offset> points = <Offset>[];
double angle = (bytes[1] / 255) * math.pi * 2;
for (int i = 0; i < n; i++) {
final int rIndex = (i * 2) % 16;
final int aIndex = (i * 2 + 1) % 16;
final double radius = 0.42 + (bytes[rIndex] / 255) * 0.52;
angle += (math.pi * 2 / n) + ((bytes[aIndex] / 255) - 0.5) * 0.95;
points.add(
Offset(math.cos(angle) * radius, math.sin(angle) * radius),
);
}
return points;
}
/// Fill color: warm accent band so glyphs stay on-brand but distinct.
Color fillColor() {
final double hue = 8 + ((bytes[2] << 8 | bytes[3]) / 65535) * 42;
return HSLColor.fromAHSL(1, hue, 0.72, 0.52).toColor();
}
Color strokeColor() => HSLColor.fromColor(fillColor()).withLightness(0.38).toColor();
/// Normalized slider position in [-1, 1] for [displayValue] in [-10, 10].
static double displayT(num displayValue) =>
(displayValue / 10).clamp(-1.0, 1.0).toDouble();
/// Display-only vertex warp; same [guid] + [displayValue] always matches.
List<Offset> displayUnitVertices(num displayValue) {
final double t = displayT(displayValue);
final List<Offset> base = unitVertices();
final int n = base.length;
final double spinSign = (bytes[9] & 1) == 0 ? 1.0 : -1.0;
final double spin = t *
math.pi *
(0.28 + (bytes[4] / 255) * 0.22) *
spinSign;
final double cosR = math.cos(spin);
final double sinR = math.sin(spin);
final double stretchY = 1 + t * (0.38 + (bytes[5] / 255) * 0.2);
final double stretchX = 1 - t * 0.14 * (bytes[6] / 255);
final List<Offset> out = <Offset>[];
for (int i = 0; i < n; i++) {
final Offset p = base[i];
final double bulge =
1 + t * ((bytes[(i * 3 + 7) % 16] / 255) - 0.5) * 0.55;
final double x = p.dx * stretchX * bulge;
final double y = p.dy * stretchY * bulge;
out.add(Offset(x * cosR - y * sinR, x * sinR + y * cosR));
}
return out;
}
/// True when the slider is at neutral zero (no directional guess).
static bool isNeutralZero(num displayValue) => displayValue == 0;
/// 0 at zero, 1 at ±10.
static double displayIntensity(num displayValue) =>
isNeutralZero(displayValue) ? 0 : (displayValue.abs() / 10).clamp(0.0, 1.0);
static const Color _negativeAccent = Color(0xFFF87171);
static const Color _neutralBlend = Color(0xFF64748B);
/// Progressive green (+) / red () fill; crystal white at zero.
Color displayFillColor(num displayValue) {
if (isNeutralZero(displayValue)) {
return const Color(0xFFF8FCFF);
}
final double intensity = displayIntensity(displayValue);
final Color target =
displayValue > 0 ? AppColors.success : _negativeAccent;
return Color.lerp(fillColor(), target, 0.25 + intensity * 0.75)!;
}
Color displayStrokeColor(num displayValue) {
if (isNeutralZero(displayValue)) {
return const Color(0xFFE2E8F0);
}
final double intensity = displayIntensity(displayValue);
final Color target =
displayValue > 0 ? AppColors.success : _negativeAccent;
return Color.lerp(_neutralBlend, target, 0.35 + intensity * 0.65)!;
}
/// Furthest unit-circle distance after [displayUnitVertices] warp (for glow sizing).
double displayMaxUnitRadius(num displayValue) {
double maxR = 0;
for (final Offset p in displayUnitVertices(displayValue)) {
maxR = math.max(maxR, p.distance);
}
return maxR > 0 ? maxR : 0.5;
}
/// Glow color for shadows (crystal white at zero, green +, red ).
Color displayGlowColor(num displayValue) {
if (isNeutralZero(displayValue)) {
return const Color(0xFFF8FCFF);
}
return displayValue > 0 ? AppColors.success : _negativeAccent;
}
double displayStrokeWidth(num displayValue) {
final double t = displayT(displayValue);
return (2.5 + t.abs() * 1.2 + (bytes[8] / 255) * 0.4).clamp(2.0, 4.0);
}
static Uint8List? tryParseUuidBytes(String guid) {
final String hex = guid.replaceAll('-', '').trim().toLowerCase();
if (hex.length != 32 || !RegExp(r'^[0-9a-f]{32}$').hasMatch(hex)) {
return null;
}
final Uint8List out = Uint8List(16);
for (int i = 0; i < 16; i++) {
out[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
}
return out;
}
static Uint8List _hashTo16Bytes(String input) {
var h1 = 0x811c9dc5;
var h2 = 0x01000193;
for (final int codeUnit in input.codeUnits) {
h1 = (h1 ^ codeUnit) * 0x01000193;
h2 = (h2 + codeUnit) * 0x85ebca6b;
}
final Uint8List out = Uint8List(16);
for (int i = 0; i < 16; i++) {
final int mix = (h1 >> (i % 16)) ^ (h2 >> ((i + 3) % 16)) ^ (i * 31);
out[i] = mix & 0xff;
}
return out;
}
}
/// Painted glyph for a question id (replaces the fixed red dot).
class QuestionGuidGlyph extends StatelessWidget {
const QuestionGuidGlyph({
super.key,
required this.guid,
this.size = 80,
this.displayValue = 0,
});
final String guid;
final double size;
/// Slider value in [-10, 10]; warps the painted glyph for display only.
final num displayValue;
@override
Widget build(BuildContext context) {
final GuidGlyphShape shape = GuidGlyphShape.fromGuid(guid);
final double intensity = GuidGlyphShape.displayIntensity(displayValue);
final Color glow = shape.displayGlowColor(displayValue);
final double strokeWidth = shape.displayStrokeWidth(displayValue);
final double baseRadius = (size / 2 - strokeWidth - 2).clamp(18.0, size / 2);
// Slightly larger bounds when warped / off-zero so the halo follows the shape.
final double paintedRadius = baseRadius * (1 + intensity * 0.1);
// Shadow box matches painted extent; blur/spread grow with that diameter.
final double bodyDiameter = paintedRadius * 2;
final double blur = bodyDiameter * (0.2 + intensity * 0.16);
final double spread = bodyDiameter * (0.03 + intensity * 0.06);
final double glowAlpha = 0.38 + intensity * 0.42;
return SizedBox(
width: size,
height: size,
child: Center(
child: Container(
width: bodyDiameter,
height: bodyDiameter,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.transparent,
boxShadow: <BoxShadow>[
BoxShadow(
color: glow.withValues(alpha: glowAlpha),
blurRadius: blur,
spreadRadius: spread * 0.35,
),
BoxShadow(
color: glow.withValues(alpha: glowAlpha * 0.45),
blurRadius: blur * 1.75,
spreadRadius: spread,
),
],
),
child: CustomPaint(
painter: _GuidGlyphPainter(
shape: shape,
displayValue: displayValue,
paintedRadius: paintedRadius,
),
),
),
),
);
}
}
class _GuidGlyphPainter extends CustomPainter {
_GuidGlyphPainter({
required this.shape,
required this.displayValue,
required this.paintedRadius,
});
final GuidGlyphShape shape;
final num displayValue;
final double paintedRadius;
@override
void paint(Canvas canvas, Size size) {
final Offset center = Offset(size.width / 2, size.height / 2);
final double maxUnitR = shape.displayMaxUnitRadius(displayValue);
final double scale = paintedRadius / maxUnitR;
final double strokeWidth = shape.displayStrokeWidth(displayValue);
final List<Offset> unit = shape.displayUnitVertices(displayValue);
final Path path = Path();
for (int i = 0; i < unit.length; i++) {
final Offset p = center + Offset(unit[i].dx * scale, unit[i].dy * scale);
if (i == 0) {
path.moveTo(p.dx, p.dy);
} else {
path.lineTo(p.dx, p.dy);
}
}
path.close();
final Color fill = shape.displayFillColor(displayValue);
final Color stroke = shape.displayStrokeColor(displayValue);
canvas.drawPath(
path,
Paint()
..color = fill
..style = PaintingStyle.fill,
);
canvas.drawPath(
path,
Paint()
..color = stroke
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeJoin = StrokeJoin.round,
);
}
@override
bool shouldRepaint(covariant _GuidGlyphPainter oldDelegate) =>
oldDelegate.shape.bytes != shape.bytes ||
oldDelegate.displayValue != displayValue ||
oldDelegate.paintedRadius != paintedRadius;
}