fix(globe): render conflict zone polygons correctly on 3D globe (#994)

* fix(globe): add polygonGeoJsonGeometry accessor for polygon rendering

globe.gl's default polygonGeoJsonGeometry accessor looks for d.geometry,
but our GlobePolygon objects store coordinates in d.coords. Without this
explicit accessor, all polygon data (conflict zones, boundaries, CII
choropleth) was silently ignored — polygonsData was set but nothing
rendered.

* fix(globe): render conflict zone polygons correctly on 3D globe

Root cause: globe.gl's ConicPolygonGeometry + earcut renders CCW exterior
rings as their complement on the sphere (filling everything EXCEPT the
polygon). The simplified CONFLICT_ZONES coords also lacked enough vertices
for proper spherical tessellation.

Fix:
- Use real GeoJSON country geometries from countriesGeoData instead of
  simplified CONFLICT_ZONES.coords (maps zone IDs to ISO-2 codes)
- Reverse winding order (CCW → CW) on all polygon rings so earcut fills
  the polygon interior, not the complement
- Apply same winding fix to GEOPOLITICAL_BOUNDARIES polygons
- Zones without country mapping (Strait of Hormuz, South Lebanon, Red Sea)
  are represented by center markers only

Tested: Iran, Ukraine, Gaza/Israel, Sudan, Myanmar all render as localized
red polygon overlays on the globe without envelope artifacts.

* fix(globe): guard against null geometry in GeoJSON features

Some GeoJSON features have null geometry which caused TypeError
in flushPolygons. Add null guards for both conflict zone and CII
choropleth polygon rendering paths.
This commit is contained in:
Elie Habib
2026-03-04 20:38:19 +04:00
committed by GitHub
parent e771c3c6e0
commit e679db9902

View File

@@ -1217,24 +1217,49 @@ export class GlobeMap {
if (this.layers.geopoliticalBoundaries) {
for (const b of GEOPOLITICAL_BOUNDARIES) {
polys.push({ coords: [b.coords], name: b.name, _kind: 'boundary', boundaryType: b.boundaryType });
// Reverse winding for globe.gl: CCW → CW to render polygon interior, not complement
const reversed = b.coords.slice().reverse();
polys.push({ coords: [reversed], name: b.name, _kind: 'boundary', boundaryType: b.boundaryType });
}
}
if (this.layers.conflicts) {
// Map conflict zone IDs to ISO-2 country codes for real GeoJSON geometry lookup.
// Using actual country geometries from countriesGeoData ensures correct rendering
// (same approach as CII choropleth which renders correctly).
const CONFLICT_ISO: Record<string, string[]> = {
iran: ['IR'],
ukraine: ['UA'],
gaza: ['PS', 'IL'],
sudan: ['SD'],
myanmar: ['MM'],
};
for (const z of CONFLICT_ZONES) {
const ring: number[][] = z.coords.map(c => [c[0], c[1]]);
if (ring.length < 3) continue;
const first = ring[0]!, last = ring[ring.length - 1]!;
if (first[0] !== last[0] || first[1] !== last[1]) ring.push(first.slice());
polys.push({
coords: [ring],
name: z.name,
_kind: 'conflict',
intensity: z.intensity ?? 'low',
parties: z.parties,
casualties: z.casualties,
});
const isoCodes = CONFLICT_ISO[z.id];
if (isoCodes && this.countriesGeoData) {
for (const feat of this.countriesGeoData.features) {
const code = feat.properties?.['ISO3166-1-Alpha-2'] as string | undefined;
if (!code || !isoCodes.includes(code)) continue;
const geom = feat.geometry;
if (!geom) continue;
const rings = geom.type === 'Polygon' ? [geom.coordinates] : geom.type === 'MultiPolygon' ? geom.coordinates : [];
for (const ring of rings) {
const reversed = ring.map((r: number[][]) => [...r].reverse());
polys.push({
coords: reversed,
name: z.name,
_kind: 'conflict',
intensity: z.intensity ?? 'low',
parties: z.parties,
casualties: z.casualties,
});
}
}
} else {
// Zones without country mapping (Strait of Hormuz, South Lebanon, etc.)
// are represented by center markers only — custom simplified polygons
// don't render correctly on globe.gl's spherical tessellation.
}
}
}
@@ -1244,6 +1269,7 @@ export class GlobeMap {
const entry = code ? this.ciiScoresMap.get(code) : undefined;
if (!entry || !code) continue;
const geom = feat.geometry;
if (!geom) continue;
const rings = geom.type === 'Polygon' ? [geom.coordinates] : geom.type === 'MultiPolygon' ? geom.coordinates : [];
const name = (feat.properties?.name as string) ?? code;
for (const ring of rings) {
@@ -1259,6 +1285,7 @@ export class GlobeMap {
const conflictAlt: Record<string, number> = { high: 0.006, medium: 0.004, low: 0.003 };
(this.globe as any)
.polygonsData(polys)
.polygonGeoJsonGeometry((d: GlobePolygon) => ({ type: 'Polygon', coordinates: d.coords }))
.polygonCapColor((d: GlobePolygon) => {
if (d._kind === 'cii') return colors[d.level!] ?? 'rgba(0,0,0,0)';
if (d._kind === 'conflict') return conflictCap[d.intensity!] ?? conflictCap.low;