layoutCampusMapRoomLabel function

CampusMapRoomLabelLayout layoutCampusMapRoomLabel({
  1. required List<List<Offset>> rings,
  2. required String number,
  3. required String name,
  4. required Color color,
  5. TextScaler textScaler = TextScaler.noScaling,
})

Fits number above name at a room's center, using projected pixel rings.

Keeps the original room-axis rotation for convex rooms. Concave rooms and rooms with holes use horizontal text in an approximate largest interior rectangle, so the entire number and name stay inside the room. Wrapping is balanced within this area. The caller must dispose the returned painter.

Implementation

CampusMapRoomLabelLayout layoutCampusMapRoomLabel({
  required List<List<Offset>> rings,
  required String number,
  required String name,
  required Color color,
  TextScaler textScaler = TextScaler.noScaling,
}) {
  final exterior = rings.first;
  final path = ui.Path()..fillType = .evenOdd;
  for (final ring in rings) {
    path.addPolygon(ring, true);
  }
  final bounds = path.getBounds();
  var center = bounds.center;

  var sin = 0.0;
  var cos = 0.0;
  for (var i = 1; i < exterior.length; i++) {
    final edge = exterior[i] - exterior[i - 1];
    final angle = math.atan2(edge.dy, edge.dx);
    sin += edge.distance * math.sin(4 * angle);
    cos += edge.distance * math.cos(4 * angle);
  }
  var angle = math.atan2(sin, cos) / 4;
  Rect rotatedBounds(double radians) {
    final points = exterior.map((point) {
      final local = point - center;
      return Offset(
        local.dx * math.cos(radians) + local.dy * math.sin(radians),
        -local.dx * math.sin(radians) + local.dy * math.cos(radians),
      );
    }).toList();
    return (ui.Path()..addPolygon(points, true)).getBounds();
  }

  var area = rotatedBounds(angle);
  if (area.height > area.width) {
    angle += math.pi / 2;
    area = rotatedBounds(angle);
  }
  if (angle > math.pi / 2) angle -= math.pi;
  if (rings.length > 1 ||
      _isConcave(exterior) ||
      area.width < area.height * 2 ||
      angle.abs() < math.pi / 36) {
    angle = 0;
  }

  Offset rotate(Offset point, double radians) => Offset(
    point.dx * math.cos(radians) - point.dy * math.sin(radians),
    point.dx * math.sin(radians) + point.dy * math.cos(radians),
  );
  final localRings = [
    for (final ring in rings)
      [for (final point in ring) rotate(point - center, -angle)],
  ];
  area = campusMapInteriorLabelRect(localRings) ?? _interiorSquare(localRings);
  center += rotate(area.center, angle);
  final width = area.width * 0.88;
  final height = area.height * 0.88;
  var fontSize = math.min(14.0, math.min(width, height) / 4);
  TextPainter emptyPainter() => TextPainter(
    text: const TextSpan(text: ''),
    textDirection: .ltr,
  )..layout();
  TextPainter createPainter() {
    if (width <= 0 || height <= 0) return emptyPainter();
    final characters = name.characters.toList();
    final perRow = math.max(1, (width / textScaler.scale(fontSize)).floor());
    final lines = math.max(1, (characters.length / perRow).ceil());
    final balanced = math.max(1, (characters.length / lines).ceil());
    final wrapped = [
      for (var start = 0; start < characters.length; start += balanced)
        characters
            .sublist(start, math.min(start + balanced, characters.length))
            .join(),
    ].join('\n');
    final text = [
      if (number.isNotEmpty && !name.contains(number)) number,
      if (wrapped.isNotEmpty) wrapped,
    ].join('\n');
    // An empty centered paragraph can produce a NaN paint offset in Flutter.
    // Unnamed map features still have geometry, but need no text layout.
    if (text.isEmpty) return emptyPainter();
    return TextPainter(
      text: TextSpan(
        text: text.spaced,
        style: TextStyle(
          color: color,
          fontSize: fontSize,
          height: 1.2,
          fontWeight: .w500,
        ),
      ),
      textAlign: .center,
      textDirection: .ltr,
      textScaler: textScaler,
    )..layout(maxWidth: width);
  }

  var painter = createPainter();
  // Re-measure after shrinking because wrapping can change the line count.
  while (painter.height > height && fontSize > 0.01) {
    fontSize *= 0.85;
    painter.dispose();
    painter = createPainter();
  }
  if (painter.height > height) {
    // Sub-pixel spaces cannot display readable text; don't paint a clipped name.
    painter.dispose();
    painter = emptyPainter();
  }
  return CampusMapRoomLabelLayout(
    center: center,
    angle: angle,
    clipPath: path,
    textPainter: painter,
  );
}