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:
- Generate a bunch of points on a sphere.
- Decide which of those points are “land”.
- Rotate those points based on the camera angles.
- Project the visible ones to 2D.
- 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);
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))
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;
}
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),
);
}
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),
);
}
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),
)
That means:
- horizontal screen position comes from
x - vertical screen position comes from
y -
zis 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;
}
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:
- glow
- ocean
- land dots
- arcs
- 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:
- GeoJsonMapSampler
- 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:
- parse GeoJSON
- convert polygon rings into internal point lists
- convert each sphere point into latitude/longitude
- 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;
}
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',
);
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;
This is effectively spherical UV mapping:
-
thetamaps around the equator -
phimaps from north pole to south pole -
uandvare 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;
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
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);
}
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);
}
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:
-
GeoJsonMapSampleruses polygon containment -
MapSampleruses 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();
}
}
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),
),
);
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)
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)