237 lines
7.2 KiB
Dart
237 lines
7.2 KiB
Dart
import 'dart:math' as math;
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.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;
|
|
}
|
|
|
|
/// Display-only fill; base identity color shifts with slider value.
|
|
Color displayFillColor(num displayValue) {
|
|
final double t = displayT(displayValue);
|
|
final HSLColor hsl = HSLColor.fromColor(fillColor());
|
|
final double hueShift = t * (12 + (bytes[7] % 20));
|
|
final double lightness = (hsl.lightness + t * 0.12).clamp(0.35, 0.68);
|
|
final double saturation =
|
|
(hsl.saturation + t.abs() * 0.1).clamp(0.5, 0.85);
|
|
return hsl
|
|
.withHue((hsl.hue + hueShift) % 360)
|
|
.withLightness(lightness)
|
|
.withSaturation(saturation)
|
|
.toColor();
|
|
}
|
|
|
|
Color displayStrokeColor(num displayValue) =>
|
|
HSLColor.fromColor(displayFillColor(displayValue))
|
|
.withLightness(0.38)
|
|
.toColor();
|
|
|
|
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 Color glow = shape.displayFillColor(displayValue);
|
|
|
|
return SizedBox(
|
|
width: size,
|
|
height: size,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
boxShadow: <BoxShadow>[
|
|
BoxShadow(
|
|
color: glow.withValues(
|
|
alpha: 0.32 + GuidGlyphShape.displayT(displayValue).abs() * 0.18,
|
|
),
|
|
blurRadius: 16 + GuidGlyphShape.displayT(displayValue).abs() * 6,
|
|
spreadRadius: 2,
|
|
),
|
|
],
|
|
),
|
|
child: CustomPaint(
|
|
painter: _GuidGlyphPainter(
|
|
shape: shape,
|
|
displayValue: displayValue,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GuidGlyphPainter extends CustomPainter {
|
|
_GuidGlyphPainter({
|
|
required this.shape,
|
|
required this.displayValue,
|
|
});
|
|
|
|
final GuidGlyphShape shape;
|
|
final num displayValue;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final Offset center = Offset(size.width / 2, size.height / 2);
|
|
final double scale = size.shortestSide * 0.42;
|
|
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);
|
|
final double strokeWidth = shape.displayStrokeWidth(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;
|
|
}
|