DEV Community

Oranekwu Gabriel Ekene
Oranekwu Gabriel Ekene

Posted on

THE GLOBE

THE GLOBE

I first saw this idea in a post by Samuel Abada on Twitter, and it immediately made me want to build my own version in Flutter. cobe_flutter is my recreation of that experience, and the name “Cobe” is kept as a credit to the original creator.

This article is going to touch on how I was able to recreate the “Globe”. I also made it into a reuseable Flutter package. Think of it like an actual globe, built from Math, painted in Flutter and exposed as a package that people can use.

This article is about how I approached it:

  • how the globe is modeled mathematically
  • how land dots are sampled
  • how markers and arcs are projected
  • how the widget stays interactive
  • how I debugged a fun little bug

What I Was Actually Building

The package API is intentionally small:

  • CobeGlobe is the widget
  • CobeController owns mutable globe state
  • CobeOptions stores render configuration
  • CobeMarker and CobeArc describe things living on the globe.

The internal structure is simple too:

  1. Generate a bunch of points on a sphere.
  2. Decide which of those points are “land”.
  3. Rotate those points based on the camera angles.
  4. Project the visible ones to 2D.
  5. Paint them.

That is the whole trick!

The Real Shape of the Problem

If you try to draw a globe by placing points on latitude/longitude lines, you get clustering near the poles. It looks wrong fast. This is one of those cases where math saves you from your own optimism.

I needed points spread fairly evenly across the sphere surface, so I used the Fibonacci sphere approach in both samplers.

final y = 1 - ((index + 0.5) * 2 / samples);
final radius = math.sqrt(math.max(0, 1 - (y * y)));
final theta = dLong * index;
final point = GlobePoint( math.cos(theta) * radius, y, math.sin(theta) * radius);
Enter fullscreen mode Exit fullscreen mode

This gives us a point on the unit sphere.

Why it works:

  • y walks from near 1 to near -1
  • for each y, the horizontal cross-section radius is sqrt(1 - y²)
  • theta advances by the golden-angle-related increment
  • the irrational angular step avoids obvious banding

That dLong value is:

math.pi * (3 - math.sqrt(5))
Enter fullscreen mode Exit fullscreen mode

That expression is tied to the golden ratio. The result is a spacing pattern that is cheap, elegant, and very hard to beat when you just want “lots of points distributed nicely on a sphere”.

The Sphere Model

The foundational 3D type is GlobePoint

class GlobePoint {
  const GlobePoint(this.x, this.y, this.z);

  final double x;
  final double y;
  final double z;
}
Enter fullscreen mode Exit fullscreen mode

Then it gets a few vector operations:

  • scale
  • +
  • -
  • length
  • normalized

That keeps the rest of the globe code readable. Once a 3D point type exists, rotations, arc midpoints, and projections become relatively easy.

From Latitude/Longitude to 3D

Markers and arcs begin as geographic coordinates:

  • latitude in degrees
  • longitude in degrees
GlobePoint _latLonTo3D(CobeLocation location) {
  final latRad = location.latitude * math.pi / 180;
  final lonRad = (location.longitude * math.pi / 180) - math.pi;
  final cosLat = math.cos(latRad);
  return GlobePoint(
    -cosLat * math.cos(lonRad),
    math.sin(latRad),
    cosLat * math.sin(lonRad),
  );
}
Enter fullscreen mode Exit fullscreen mode

Let’s unpack that.

Given latitude φ and longitude λ, the classic sphere mapping is:

  • x = cos(φ) cos(λ)
  • y = sin(φ)
  • z = cos(φ) sin(λ)

Rotating the Globe

The globe’s orientation is controlled by phi and theta in CobeOptions.

GlobePoint _applyRotation(GlobePoint point, CobeOptions options) {
  final cx = math.cos(options.theta);
  final cy = math.cos(options.phi);
  final sx = math.sin(options.theta);
  final sy = math.sin(options.phi);

  return GlobePoint(
    (cy * point.x) + (sy * point.z),
    (sy * sx * point.x) + (cx * point.y) - (cy * sx * point.z),
    (-sy * cx * point.x) + (sx * point.y) + (cy * cx * point.z),
  );
}
Enter fullscreen mode Exit fullscreen mode

This is effectively a composed rotation.

Conceptually:

  • phi controls horizontal spin around the globe
  • theta controls tilt

The code is the matrix multiplication written out explicitly. This avoids creating temporary matrix objects and keeps rendering tight.

From 3D Back to 2D

Once a point has been rotated, it still has to be painted on a flat canvas, because screens remain stubbornly two-dimensional.

The package uses a straightforward orthographic-style projection:

Offset(
  center.dx + (rotated.x * scaleFactor),
  center.dy - (rotated.y * scaleFactor),
)
Enter fullscreen mode Exit fullscreen mode

That means:

  • horizontal screen position comes from x
  • vertical screen position comes from y
  • z is used for visibility/depth decisions

This is an important design choice.

I did not want heavy perspective distortion. COBE-style globes look good partly because they read as clean, graphic, and calm. Orthographic projection fits that look better than aggressive perspective.

The z value still matters, though. It determines whether a point is on the front hemisphere:

if (rotated.z <= 0) {
  continue;
}
Enter fullscreen mode Exit fullscreen mode

That is how land dots on the back side of the globe politely avoid ruining the illusion.

The Painter Pipeline

Rendering happens inside _CobeGlobePainter in lib/src/cobe_globe.dart.

The paint order is deliberate:

  1. glow
  2. ocean
  3. land dots
  4. arcs
  5. markers

That ordering does a lot of visual work.

If you paint in the wrong order:

  • the globe loses depth
  • markers look pasted on
  • arcs feel detached
  • glow becomes mud

The land itself is not a filled shape. It is a cloud of visible points on the front hemisphere. That was an aesthetic choice, but also a practical one: the dotted look is part of the package identity.

Two Ways to Decide What Counts as Land

This package has two samplers:

  1. GeoJsonMapSampler
  2. MapSampler

They answer the same question:

“Given a point on the sphere, is this land?”, but they answer it very differently.

1. GeoJSON Sampler

I load polygon data from countries.geojson.

The process is:

  1. parse GeoJSON
  2. convert polygon rings into internal point lists
  3. convert each sphere point into latitude/longitude
  4. test whether that geographic point lies inside a polygon

The polygon containment uses a ray-casting style algorithm:

final intersects =
    ((yi > point.lat) != (yj > point.lat)) &&
    (point.lon <
        ((xj - xi) * (point.lat - yi) / ((yj - yi) + 1e-12)) + xi);
if (intersects) {
  inside = !inside;
}
Enter fullscreen mode Exit fullscreen mode

This approach is geometrically meaningful and usually closer to “truth”, but it is more computationally expensive than sampling a raster mask.

2. Image Sampler

The land test comes from a texture:

final asset = await rootBundle.load(
  'packages/cobe_flutter/assets/texture.png',
);
Enter fullscreen mode Exit fullscreen mode

That texture is a grayscale mask. A point on the globe is converted into UV coordinates, then a pixel is sampled.

The mapping looks like this:

final phi = math.asin(point.y);
final theta = math.atan2(point.z, -point.x);
final u = (((theta * 0.5) / math.pi) % 1 + 1) % 1;
final v = (((-((phi / math.pi) + 0.5)) % 1) + 1) % 1;
Enter fullscreen mode Exit fullscreen mode

This is effectively spherical UV mapping:

  • theta maps around the equator
  • phi maps from north pole to south pole
  • u and v are normalized into [0, 1]

Then the code samples a pixel:

final px = (u * (texture.width - 1)).round();
final py = (v * (texture.height - 1)).round();
final index = ((py * texture.width) + px) * 4;
return texture.bytes[index] / 255;
Enter fullscreen mode Exit fullscreen mode

And that value is used as a land mask.

This method is simple and fast. It is also more fragile:

  • texture resolution matters
  • threshold choice matters
  • UV orientation matters
  • raster edges are never as precise as polygon boundaries

The Bug

At one point, the GeoJSON sampler looked correct, but the image sampler looked reversed:

  • land was sparse or clear
  • water was getting the dots

The Bug

Which is a terrible feature unless you are building a globe for fish.

The bug was in lib/src/map_sampler.dart, specifically this classification:

if (_sampleMap(texture, point) < 0.5) {
  points.add(point);
}
Enter fullscreen mode Exit fullscreen mode

That meant dark pixels were being treated as land.

But for the actual mask I was using, the intended semantics were the reverse: bright pixels corresponded to land.

So the fix was:

if (_sampleMap(texture, point) >= 0.5) {
  points.add(point);
}
Enter fullscreen mode Exit fullscreen mode

Why the Two Samplers Are Similar, Not Equal

One natural question is:

“If both samplers are trying to detect land, shouldn’t they return the same points?”

No. They should be similar enough visually, but not identical.

Why:

  • GeoJsonMapSampler uses polygon containment
  • MapSampler uses a raster texture

So the right expectation is:

  • same broad land/water orientation
  • comparable land coverage
  • visually consistent output

Not:

  • exact point-for-point equality

Markers, Arcs, and Overlay Projections

The globe is not just dots. It also supports markers, arcs, and overlay UI.

That meant I needed a way to project world-space entities into screen-space data that other widgets could use.

This is modeled by GlobeProjection and GlobeSceneData in lib/src/cobe_models.dart.

CobeGlobe computes these projections during build and passes them to overlayBuilder.

That is useful because it separates:

  • paint-time globe rendering
  • regular Flutter widget overlays

This is the kind of boundary I like. The globe painter can stay focused on canvas work, while overlays remain ordinary widgets that can show labels, cards, annotations, and whatever other chaos product requirements invent next Tuesday.

Controller-Driven State

class CobeController extends ChangeNotifier {
  CobeController(this._options);

  CobeOptions _options;

  CobeOptions get options => _options;

  void update(CobeOptionsPatch patch) {
    _options = _options.merge(patch);
    notifyListeners();
  }

  void replace(CobeOptions next) {
    _options = next;
    notifyListeners();
  }
}
Enter fullscreen mode Exit fullscreen mode

I wanted:

  • easy animation updates
  • easy user drag updates
  • minimal public API complexity

The widget listens to the controller and repaints when options change. That gives a nice “reactive enough” model without dragging in extra state machinery.

Auto-Rotation and Dragging

CobeGlobe uses a Ticker for auto-rotation and GestureDetector for dragging.

The auto-rotation logic is based on elapsed time deltas:

final deltaSeconds =
    (elapsed - lastTick).inMicroseconds / Duration.microsecondsPerSecond;

widget.controller.update(
  CobeOptionsPatch(
    phi: widget.controller.options.phi +
        (widget.autoRotateSpeed * deltaSeconds),
  ),
);
Enter fullscreen mode Exit fullscreen mode

This matters because animation should be time-based, not frame-count-based. Otherwise different devices rotate the globe at different speeds.

Dragging updates:

  • phi from horizontal pointer movement
  • theta from vertical pointer movement

with sensible clamping on tilt:

.clamp(-math.pi / 2, math.pi / 2)
Enter fullscreen mode Exit fullscreen mode

Testing the Package

I also added package-level tests for:

  • controller updates
  • options merging and copying
  • vector math
  • widget behavior
  • sampler behavior

The test suite lives in the test folder.

Package Information

Package Name: cobe_flutter
GitHub Repository: https://github.com/gabbygreat/cobe_flutter

Happy coding!

Top comments (0)